import test from 'node:test'; import assert from 'node:assert/strict'; import { verifySourceBatch } from '../lib/source-batch.mjs'; const config = { mysql: { database: 'source_batch_test' } }; const first = { contentId: '9007199254740993', configId: '5', path: '相册/Résumé.PNG', size: 1234, sha256: 'a'.repeat(64) }; const second = { contentId: '9007199254740995', configId: '9007199254740999', path: '头像.png', size: 4321, sha256: 'b'.repeat(64) }; const latest = item => ({ id: item.contentId, expected_content_id: item.contentId }); const locked = (...items) => items.map(item => ({ id: item.contentId })); const content = (item, overrides = {}) => ({ id: item.contentId, config_id: item.configId, path: item.path, size: String(item.size), sha256: item.sha256, ...overrides }); const changed = error => error.code === 'MIGRATION_SOURCE_CHANGED'; class FakeDb { constructor(responses) { this.responses = responses; this.calls = []; } async execute(sql, params) { assert.match(sql.trim(), /^SELECT\b/); if (this.calls.length === 1) assert.doesNotMatch(sql, /FOR UPDATE|LOCK IN SHARE MODE/i); else assert.match(sql, /FOR UPDATE$/); assert.doesNotMatch(sql, /\b(?:START TRANSACTION|BEGIN|COMMIT|ROLLBACK)\b/i); this.calls.push({ sql, params }); if (this.calls.length > this.responses.length) throw new Error('Unexpected database query'); const result = this.responses[this.calls.length - 1]; if (result instanceof Error) throw result; return [result]; } } test('validates in three SELECTs with a primary-key-only range lock and a separate digest read', async () => { const db = new FakeDb([locked(first, second), [latest(second), latest(first)], [content(first), content(second)]]); await verifySourceBatch(db, config, [second, first]); assert.equal(db.calls.length, 3); const [range, metadata, hashes] = db.calls; assert.match(range.sql, /^SELECT id FROM /); assert.match(range.sql, /WHERE id>=\? ORDER BY id FOR UPDATE$/); assert.doesNotMatch(range.sql, /\b(?:content|deleted|config_id|path|CASE|LIMIT)\b|SHA2|OCTET_LENGTH|id\s+IN\s*\(/i, 'lock every row and gap above the minimum ID, including deleted and unrelated-path rows'); assert.deepEqual(range.params, [first.contentId]); assert.match(metadata.sql, /^SELECT id,CASE WHEN config_id=\? AND path=\? THEN \?/); assert.match(metadata.sql, /END AS expected_content_id/); assert.match(metadata.sql, /WHERE deleted=0 AND id>=\? AND \(\(config_id=\? AND path=\? AND id>=\?\) OR/); assert.match(metadata.sql, /ORDER BY id DESC$/); assert.doesNotMatch(metadata.sql, /\bcontent\b|OCTET_LENGTH|SHA2|\bLIMIT\b|id\s+IN\s*\(/i, 'metadata lookup must examine newer IDs without projecting BLOB expressions'); assert.deepEqual(metadata.params, [ second.configId, second.path, second.contentId, first.configId, first.path, first.contentId, first.contentId, second.configId, second.path, second.contentId, first.configId, first.path, first.contentId ]); assert.match(hashes.sql, /OCTET_LENGTH\(content\) AS size,LOWER\(SHA2\(content,256\)\) AS sha256/); assert.match(hashes.sql, /WHERE deleted=0 AND id IN \(\?,\?\) FOR UPDATE$/); assert.doesNotMatch(hashes.sql, /ORDER BY|\bCASE\b|\bJOIN\b|\bLIMIT\b/i); assert.deepEqual(hashes.params, [second.contentId, first.contentId]); }); test('uses server-provided grouping IDs and accepts collation-equivalent case/accent spellings', async () => { const db = new FakeDb([locked(first), [latest(first)], [content(first, { path: '相册/RESUME.png' })]]); await verifySourceBatch(db, config, [first]); assert.deepEqual(db.calls[1].params, [first.configId, first.path, first.contentId, first.contentId, first.configId, first.path, first.contentId]); assert.doesNotMatch(db.calls[1].sql, /BINARY\s+path|LOWER\s*\(path\)/i); }); test('rejects a newer active version before performing the batch digest read', async () => { const db = new FakeDb([locked(first, second), [ latest(second), latest(first), { id: '9007199254740994', expected_content_id: first.contentId } ]]); await assert.rejects(verifySourceBatch(db, config, [first, second]), changed); assert.equal(db.calls.length, 2); }); test('rejects a deleted/missing group or an unexpected server grouping ID before reading hashes', async t => { for (const [label, rows] of [ ['missing group', [latest(first)]], ['all deleted', []], ['unexpected group', [latest(first), { id: second.contentId, expected_content_id: '88' }]], ['null CASE result', [latest(first), { id: second.contentId, expected_content_id: null }]] ]) { await t.test(label, async () => { const db = new FakeDb([locked(first, second), rows]); await assert.rejects(verifySourceBatch(db, config, [first, second]), changed); assert.equal(db.calls.length, 2); }); } }); test('rejects disappearance, size changes, hash changes and wrong IDs in the digest result', async t => { for (const [label, rows] of [ ['missing content', [content(first)]], ['wrong size', [content(first), content(second, { size: second.size + 1 })]], ['same size different hash', [content(first), content(second, { sha256: 'c'.repeat(64) })]], ['wrong ID', [content(first), content(second, { id: '9007199254740996' })]], ['duplicate ID', [content(first), content(first)]] ]) { await t.test(label, async () => { const db = new FakeDb([locked(first, second), [latest(first), latest(second)], rows]); await assert.rejects(verifySourceBatch(db, config, [first, second]), changed); assert.equal(db.calls.length, 3); }); } }); test('preserves unsigned bigint IDs and binds all paths instead of interpolating them', async () => { const large = { ...first, contentId: '18446744073709551615', path: "x.png' OR '1'='1" }; const db = new FakeDb([locked(large), [latest(large)], [content(large)]]); await verifySourceBatch(db, { mysql: { database: 'app`db' } }, [large]); for (const call of db.calls) { assert.match(call.sql, /`app``db`\.`infra_file_content`/); assert.ok(!call.sql.includes(large.path)); assert.ok(!call.sql.includes(large.contentId)); } assert.deepEqual(db.calls[2].params, [large.contentId]); }); test('rejects empty batches, duplicate IDs and unsafe numeric IDs before any SQL', async t => { for (const [label, batch, expectedError] of [ ['empty batch', [], /不能为空/], ['duplicate content ID', [first, { ...first }], /重复/], ['unsafe content ID', [{ ...first, contentId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/], ['unsafe config ID', [{ ...first, configId: Number.MAX_SAFE_INTEGER + 1 }], /安全整数/], ['invalid expected size', [{ ...first, size: Number.MAX_SAFE_INTEGER + 1 }], /预期大小/] ]) { await t.test(label, async () => { const db = new FakeDb([]); await assert.rejects(verifySourceBatch(db, config, batch), expectedError); assert.equal(db.calls.length, 0); }); } }); test('propagates database lock/read errors and leaves rollback to the transaction owner', async () => { const lockFailure = Object.assign(new Error('lock wait timed out'), { code: 'ER_LOCK_WAIT_TIMEOUT' }); const db = new FakeDb([lockFailure]); await assert.rejects(verifySourceBatch(db, config, [first]), error => error === lockFailure); assert.equal(db.calls.length, 1); });