import assert from 'node:assert/strict'; import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { sync, loadState, type SyncEvent } from '../src/cli/sync.ts'; import type { ApiClient, Manifest, ManifestEntry } from '../src/cli/client.ts'; /** An ApiClient stand-in: the mirror semantics are what is under test, not HTTP. */ function fakeApi(entries: ManifestEntry[], bytesFor: (id: string) => Buffer = () => Buffer.from('x')): ApiClient { return { manifest: async (): Promise => ({ crawlId: 7, cursor: '7', count: entries.length, entries, }), file: async (fileId: string) => new Response(bytesFor(fileId)), } as unknown as ApiClient; } const entry = (over: Partial = {}): ManifestEntry => ({ fileId: 'f1', name: 'a.pdf', path: 'Kurs/a.pdf', size: 1, mimeType: 'application/pdf', courseId: 'c1', courseTitle: 'Kurs', status: 'added', ...over, }); const run = async (api: ApiClient, root: string, over: Partial[2]> = {}) => { const events: SyncEvent[] = []; const summary = await sync(api, root, { dryRun: false, prune: false, full: false, concurrency: 2, onEvent: (event) => events.push(event), ...over, }); return { summary, events }; }; async function tempRoot(): Promise { return mkdtemp(join(tmpdir(), 'scsync-')); } describe('sync', () => { it('downloads new files and records them by id', async () => { const root = await tempRoot(); const { summary } = await run(fakeApi([entry({ size: 5 })], () => Buffer.from('hello')), root); assert.equal(summary.downloaded, 1); assert.equal(await readFile(join(root, 'Kurs/a.pdf'), 'utf8'), 'hello'); const state = await loadState(root); assert.equal(state.files.f1?.path, 'Kurs/a.pdf'); assert.equal(state.cursor, '7'); }); it('is idempotent — a second run downloads nothing', async () => { const root = await tempRoot(); const api = fakeApi([entry({ size: 5 })], () => Buffer.from('hello')); await run(api, root); const { summary } = await run(api, root); assert.equal(summary.downloaded, 0); assert.equal(summary.skipped, 1); }); it('re-downloads when the local size does not match', async () => { const root = await tempRoot(); const api = fakeApi([entry({ size: 5 })], () => Buffer.from('hello')); await run(api, root); await writeFile(join(root, 'Kurs/a.pdf'), 'tru'); // truncated const { summary, events } = await run(api, root); assert.equal(summary.downloaded, 1); assert.ok(events.some((e) => e.type === 'download' && e.reason === 'size-mismatch')); assert.equal(await readFile(join(root, 'Kurs/a.pdf'), 'utf8'), 'hello'); }); it('moves rather than re-downloads when the upstream path changes', async () => { const root = await tempRoot(); const api = fakeApi([entry({ size: 5 })], () => Buffer.from('hello')); await run(api, root); // Same file record, new breadcrumb — a renamed column upstream. const moved = fakeApi([entry({ size: 5, path: 'Kurs/Neu/a.pdf' })], () => Buffer.from('hello')); const { summary } = await run(moved, root); assert.equal(summary.moved, 1); assert.equal(summary.downloaded, 0, 'a rename must not cost a re-download'); assert.ok((await stat(join(root, 'Kurs/Neu/a.pdf'))).isFile()); }); it('keeps files removed upstream unless --prune is given', async () => { const root = await tempRoot(); await run(fakeApi([entry({ size: 5 })], () => Buffer.from('hello')), root); const gone = fakeApi([entry({ size: 5, status: 'removed' })]); const { summary } = await run(gone, root); assert.equal(summary.kept, 1); assert.equal(summary.removed, 0); assert.ok((await stat(join(root, 'Kurs/a.pdf'))).isFile(), 'the local copy must survive by default'); }); it('deletes upstream-removed files when pruning', async () => { const root = await tempRoot(); await run(fakeApi([entry({ size: 5 })], () => Buffer.from('hello')), root); const gone = fakeApi([entry({ size: 5, status: 'removed' })]); const { summary } = await run(gone, root, { prune: true }); assert.equal(summary.removed, 1); await assert.rejects(() => stat(join(root, 'Kurs/a.pdf'))); }); it('writes nothing in a dry run', async () => { const root = await tempRoot(); const { summary } = await run(fakeApi([entry({ size: 5 })]), root, { dryRun: true }); assert.equal(summary.downloaded, 1, 'reported as would-download'); await assert.rejects(() => stat(join(root, 'Kurs/a.pdf')), 'but nothing written'); assert.equal((await loadState(root)).cursor, undefined, 'and the cursor is not advanced'); }); it('refuses a manifest path that would escape the sync root', async () => { const root = await tempRoot(); const evil = fakeApi([entry({ path: '../../escaped.pdf' })]); const { summary, events } = await run(evil, root); assert.equal(summary.failed, 1); assert.ok(events.some((e) => e.type === 'error' && /traversal/.test(e.message))); await assert.rejects(() => stat(join(root, '../../escaped.pdf'))); }); it('leaves no .part file behind after a successful download', async () => { const root = await tempRoot(); await run(fakeApi([entry({ size: 5 })], () => Buffer.from('hello')), root); await assert.rejects(() => stat(join(root, 'Kurs/a.pdf.part'))); }); });