140 lines
7.2 KiB
JavaScript
140 lines
7.2 KiB
JavaScript
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);
|
|
});
|