128 lines
6.8 KiB
JavaScript
128 lines
6.8 KiB
JavaScript
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);
|
|
});
|