// @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest'; import { createComponentBackupApiDataSource } from './component-backup-api-data-source.js'; const catalog = { items: [ { key: 'devices', label: '设备与分组', description: '节点地址、分组、标签与能力快照。', rows: 7, }, ], directory: '/Users/chick/.local/opt/multi-simadmin/data/backups', }; const backup = { filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json', createdAt: '2026-09-04T03:30:00.000Z', sizeBytes: 4096, appVersion: '0.1.0', formatVersion: 1, note: '升级前', automatic: true, integrity: 'ok', compatible: true, components: [{ key: 'devices', label: '设备与分组', description: '', rows: 7 }], }; const settings = { enabled: true, components: ['devices'], timeOfDay: '03:30', weekday: -1, maximumCount: 14, lastRunAt: null, }; const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status }); describe('component backup API data source', () => { it('reads the catalog, list and schedule with same-origin controls', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(json(catalog)) .mockResolvedValueOnce(json({ items: [backup] })) .mockResolvedValueOnce(json(settings)); const source = createComponentBackupApiDataSource(fetcher); const signal = new AbortController().signal; await expect(source.catalog(signal)).resolves.toEqual(catalog); await expect(source.list(signal)).resolves.toEqual([backup]); await expect(source.autoSettings(signal)).resolves.toEqual(settings); expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/system/component-backups/catalog'); expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/system/component-backups'); expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/system/component-backups/auto/settings'); expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin', signal }); }); it('posts create, restore and delete against the encoded filename', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(json(backup, 201)) .mockResolvedValueOnce( json({ written: { devices: 7 }, safetyBackup: 'multi-simadmin-components-auto-x.json' }), ) .mockResolvedValueOnce(json({ filename: backup.filename })); const source = createComponentBackupApiDataSource(fetcher); await expect(source.create(['devices'], '升级前')).resolves.toEqual(backup); await expect(source.restore(backup.filename, ['devices'])).resolves.toBe( 'multi-simadmin-components-auto-x.json', ); await expect(source.remove(backup.filename)).resolves.toBeUndefined(); expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', body: JSON.stringify({ components: ['devices'], note: '升级前' }), }); expect(fetcher.mock.calls[1]?.[0]).toBe( `/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/restore`, ); expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' }); }); it('previews an archive and builds a safe download URL', async () => { const fetcher = vi.fn().mockResolvedValueOnce(json(backup)); const source = createComponentBackupApiDataSource(fetcher); await expect(source.preview(backup.filename)).resolves.toEqual(backup); expect(source.backupDownloadUrl(backup.filename)).toBe( `/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/download`, ); expect(fetcher.mock.calls[0]?.[0]).toBe( `/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/preview`, ); expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' }); }); it('saves the schedule and rejects an unknown component key', async () => { const fetcher = vi.fn().mockResolvedValue(json(settings)); const source = createComponentBackupApiDataSource(fetcher); await expect( source.saveAutoSettings({ enabled: true, components: ['devices'], timeOfDay: '03:30', weekday: -1, maximumCount: 14, }), ).resolves.toEqual(settings); await expect(source.create(['secretReferences' as never], '')).rejects.toThrowError( 'Component backup response is invalid.', ); }); it('refuses a payload whose integrity flag is not understood', async () => { const fetcher = vi .fn() .mockResolvedValue(json({ items: [{ ...backup, integrity: 'maybe' }] })); await expect(createComponentBackupApiDataSource(fetcher).list()).rejects.toThrowError( 'Component backup response is invalid.', ); }); it('refuses a filename that could escape the backup directory', async () => { const fetcher = vi.fn().mockResolvedValue(json(backup)); await expect( createComponentBackupApiDataSource(fetcher).remove('../secret.json'), ).rejects.toThrowError('Component backup response is invalid.'); }); it('refuses a catalog that does not say where the backups live', async () => { const fetcher = vi.fn().mockResolvedValue(json({ items: catalog.items })); await expect(createComponentBackupApiDataSource(fetcher).catalog()).rejects.toThrowError( 'Component backup response is invalid.', ); }); it('refuses a restore that cannot prove it took a safety snapshot first', async () => { const fetcher = vi.fn().mockResolvedValue(json({ written: { devices: 7 } })); await expect( createComponentBackupApiDataSource(fetcher).restore(backup.filename, ['devices']), ).rejects.toThrowError('Component backup response is invalid.'); }); });