import test from 'node:test'; import assert from 'node:assert/strict'; import { loadSourceIndex } from '../lib/source-index.mjs'; const config = { mysql: { database: 'source_index_test' }, sourceConfigIds: ['9007199254740999', '5'] }; const makeFile = (value, contentId, overrides = {}) => ({ id: value, config_id: '5', path: 'image.png', url: 'https://old.example/image.png', type: 'image/png', size: 3, content_id: contentId, ...overrides }); class FakeDb { constructor(batches, stageEvents) { this.batches = batches; this.stageEvents = stageEvents; this.calls = []; } async execute(sql, params) { assert.match(sql.trim(), /^SELECT\b/); assert.doesNotMatch(sql, /\b(?:OCTET_LENGTH|SHA2)\s*\(/i); assert.doesNotMatch(sql, /\bcontent\b|SELECT\s+\*/i, 'only metadata columns may be selected'); assert.doesNotMatch(sql, /\b(?:NOT EXISTS|CREATE|INSERT|UPDATE|DELETE|ALTER)\b/i); assert.match(sql, /GROUP BY config_id,path/); assert.match(sql, /MAX\(id\) AS id/); assert.match(sql, /WHERE deleted=0 AND config_id IN \(\?,\?\)/); if (this.stageEvents) assert.equal(this.stageEvents.length, this.calls.length + 1, 'announce each phase before executing it'); this.calls.push({ sql, params }); if (this.calls.length > 3) throw new Error('Unexpected per-file query'); return [this.batches[this.calls.length - 1]]; } } test('loads all metadata in exactly three SELECTs and preserves bigint IDs and raw spellings', async () => { const stages = []; const db = new FakeDb([ [ { content_id: '9007199254740995', config_id: '9007199254740999', path: '头像.png' }, { content_id: '9', config_id: '5', path: 'IMAGE.png' } ], [makeFile('9007199254740996', '9'), makeFile('2', '9', { path: 'IMAGE.png' }), makeFile('10', null)], [ { content_id: '9', path: Buffer.from('image.png') }, { content_id: '9', path: Buffer.from('IMAGE.png') }, { content_id: '9', path: Buffer.from('image.png ') }, { content_id: '9', path: Buffer.from('image.png') }, { content_id: '9007199254740995', path: Buffer.from('头像.png') } ] ], stages); const result = await loadSourceIndex(db, config, { progress: { stage: message => stages.push(message) } }); assert.equal(db.calls.length, 3); assert.equal(stages.length, 3); assert.equal(result.maxId, '9007199254740995'); assert.deepEqual(result.groups.map(group => group.contentId), ['9', '9007199254740995']); assert.equal(result.groups[0].path, 'IMAGE.png', 'the latest row supplies the canonical raw path'); assert.deepEqual(result.groups[0].files.map(file => file.id), ['2', '9007199254740996']); assert.deepEqual(result.groups[0].spellings, ['IMAGE.png', 'image.png', 'image.png ']); assert.equal(result.groups[1].configId, '9007199254740999'); assert.deepEqual(result.groups[1].files, [], 'orphan BLOBs remain candidates'); assert.deepEqual(result.groups[1].spellings, ['头像.png']); assert.deepEqual(result.missingFiles.map(file => file.id), ['10']); assert.equal(Object.hasOwn(result.missingFiles[0], 'content_id'), false); assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']); assert.deepEqual(db.calls[1].params, ['5', '9007199254740999', '5', '9007199254740999']); }); test('upper bound applies after grouping and files with newer content are never marked missing', async () => { const bound = '9007199254740993'; const db = new FakeDb([ [{ content_id: '8', config_id: '5', path: 'image.png' }], [makeFile('1', '8'), makeFile('2', '9007199254740994'), makeFile('3', null)], [{ content_id: '8', path: 'image.png' }, { content_id: '9007199254740994', path: 'newer.png' }] ]); const result = await loadSourceIndex(db, config, { maxId: bound }); assert.equal(result.maxId, bound); assert.deepEqual(result.groups.map(group => group.contentId), ['8']); assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']); assert.deepEqual(result.missingFiles.map(file => file.id), ['3']); assert.match(db.calls[0].sql, /GROUP BY config_id,path\) g ON g.id=c.id WHERE g.id<=\?/); assert.doesNotMatch(db.calls[0].sql, /WHERE deleted=0[^)]*id\s*[<>=]/i); assert.doesNotMatch(db.calls[1].sql, /g\.id<=|id<=/); assert.match(db.calls[2].sql, /WHERE c.deleted=0 AND g.id<=\?/); assert.deepEqual(db.calls[0].params, ['5', '9007199254740999', bound]); assert.deepEqual(db.calls[2].params, ['5', '9007199254740999', bound]); }); test('all path associations use server-generated content IDs, never JavaScript case folding', async () => { const db = new FakeDb([ [{ content_id: '7', config_id: '5', path: 'Résumé.png' }, { content_id: '8', config_id: '5', path: 'resume.png' }], [makeFile('1', '7', { path: 'RESUME.png' }), makeFile('2', '8', { path: 'resume.png' })], [{ content_id: '7', path: Buffer.from('Résumé.png') }, { content_id: '7', path: Buffer.from('RESUME.png') }, { content_id: '8', path: Buffer.from('resume.png') }] ]); const result = await loadSourceIndex(db, config); assert.equal(result.groups.length, 2); assert.deepEqual(result.groups[0].files.map(file => file.id), ['1']); assert.deepEqual(result.groups[1].files.map(file => file.id), ['2']); assert.match(db.calls[1].sql, /g\.config_id=f\.config_id AND g\.path=f\.path/); assert.match(db.calls[2].sql, /SELECT DISTINCT g.id AS content_id,BINARY c.path AS path/); assert.match(db.calls[2].sql, /g\.config_id=c\.config_id AND g\.path=c\.path/); assert.match(db.calls[1].sql, /WHERE f.deleted=0/); assert.match(db.calls[2].sql, /WHERE c.deleted=0/); }); test('empty content results still report missing files and use a zero upper bound', async () => { const db = new FakeDb([[], [makeFile('4', null)], []]); const result = await loadSourceIndex(db, config); assert.equal(db.calls.length, 3); assert.equal(result.maxId, '0'); assert.deepEqual(result.groups, []); assert.deepEqual(result.missingFiles.map(file => file.id), ['4']); const bounded = await loadSourceIndex(new FakeDb([[], [], []]), config, { maxId: '0' }); assert.equal(bounded.maxId, '0'); }); test('source IDs are deduplicated and database identifiers are safely quoted', async () => { const db = new FakeDb([[], [], []]); const unusual = { mysql: { database: 'app`db' }, sourceConfigIds: ['9007199254740999', '5', '5'] }; await loadSourceIndex(db, unusual); assert.match(db.calls[0].sql, /`app``db`\.`infra_file_content`/); assert.deepEqual(db.calls[0].params, ['5', '9007199254740999']); }); test('rejects unsafe numeric boundaries and invalid source configurations without SQL', async () => { const db = new FakeDb([[], [], []]); await assert.rejects(loadSourceIndex(db, config, { maxId: Number.MAX_SAFE_INTEGER + 1 }), /大整数/); await assert.rejects(loadSourceIndex(db, config, { maxId: '-1' }), /非负整数/); await assert.rejects(loadSourceIndex(db, { ...config, sourceConfigIds: [] }), /至少一个/); assert.equal(db.calls.length, 0); });