Files
dzc f55561c98d feat:上传到阿里云oss
feat:把以前的图片迁移到阿里云oss
2026-11-05 15:22:55 +08:00

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);
});