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

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