feat(source): 媒体映射侧车导出与登记

Plan 3 Task 0(评审 Issue 1):旧 cms 库 media_assets 的 source_url→file_path
映射只读导出为侧车 JSON(859 条,覆盖率对照 WXR 855 attachment_url:精确命中
836 / 归一命中 0 / 未命中 19,97.8%),register 新增 --media-map 输入通道
(kind=manual_url_list 快照,形状校验 INVALID_MEDIA_MAP,metadata 含条目数,
幂等复用)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 14:42:32 +08:00
parent ceb6106025
commit a227cfac49
5 changed files with 515 additions and 14 deletions
+109 -1
View File
@@ -12,7 +12,9 @@
// ENOENT / 类型不符均清晰报错且零落库;
// 6. 报告落 artifacts/register/<runId>/reports/register.json(runId=时间戳+随机);
// 7. 并发登记同输入恰好一组行(DB 单事务 + 唯一约束/advisory lock 收敛)、双方同 id;
// 8. 空媒体目录拒绝(EMPTY_MEDIA_DIR)、metadata 固化 storageRoot、siteCreated 语义。
// 8. 空媒体目录拒绝(EMPTY_MEDIA_DIR)、metadata 固化 storageRoot、siteCreated 语义;
// 9. media-map 侧车输入(Plan 3 Task 0):kind=manual_url_list 快照、sha256=文件哈希、
// metadata 含条目数、幂等重跑复用;非法 JSON(非数组/条目字段缺失)→ INVALID_MEDIA_MAP 零落库。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
// 注:符号链接跳过用目录 junction 验证(Windows 免特权;非 Windows 退化为普通符号链接);
// 创建失败(极端沙箱)时该断言静默放行——junction 创建不是本测试的被测契约。
@@ -481,6 +483,112 @@ describe('source:register 服务', () => {
})
})
it('media-map 输入:kind=manual_url_list 快照(sha256=文件哈希、metadata 含条目数),幂等重跑复用', async () => {
await withTestDb(async (db) => {
await withCtx(async (ctx) => {
// 侧车 JSON(Plan 3 Task 0 形态):[{sourceUrl, filePath}] 数组——真实导出脚本的输出同构
const entries = [
{
sourceUrl: 'https://hesupipe.com/wp-content/uploads/2019/12/Amazon.png',
filePath: 'f249747a-50d3-48ed-a28a-981cf1d5ee7f.png',
},
{
sourceUrl: 'https://hesupipe.com/wp-content/uploads/2019/12/map-location.jpg',
filePath: '0e6d0fd7-8d5f-4f57-a541-40a8f2cb9c02.jpg',
},
]
const mapContent = `${JSON.stringify(entries, null, 2)}\n`
const mapFile = join(ctx.work, 'media-map-hesupipe.json')
await writeFile(mapFile, mapContent, 'utf8')
const key = `sfreg-${rand()}`
const makeMapInput = (): RegisterInput => ({
siteKey: key,
mediaMapPath: mapFile,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.reportRoot,
})
const r1 = await registerSource(db, makeMapInput())
try {
expect(r1.snapshots).toHaveLength(1)
const s = r1.snapshots[0]!
expect(s.kind).toBe('manual_url_list')
// sha256 = 文件内容哈希(与 wxr/sitemap 文件输入同口径)
expect(s.sha256).toBe(sha256(mapContent))
expect(s.byteSize).toBe(Buffer.byteLength(mapContent))
expect(s.storagePath).toBe(`snapshots/manual_url_list/${s.sha256.slice(0, 2)}/${s.sha256}`)
expect(existsSync(join(ctx.storageRoot, s.storagePath))).toBe(true)
expect(await readFile(join(ctx.storageRoot, s.storagePath), 'utf8')).toBe(mapContent)
// metadata 含条目数(Task 0 契约)与 sourceName;storageRoot 固化
expect(s.metadata).toMatchObject({ entryCount: 2, sourceName: 'media-map-hesupipe.json' })
expect((s.metadata as { storageRoot: string }).storageRoot).toBe(resolve(ctx.storageRoot))
// DB 行:kind 枚举值落库正确、metadata jsonb 含条目数
const rows = (await db.execute(
`select kind, sha256, byte_size, storage_path, metadata from source_snapshots where site_id = '${r1.siteId}'`,
)) as { kind: string; sha256: string; byte_size: string; storage_path: string; metadata: { entryCount: number } }[]
expect(rows).toHaveLength(1)
expect(rows[0]!.kind).toBe('manual_url_list')
expect(rows[0]!.sha256).toBe(sha256(mapContent))
expect(Number(rows[0]!.byte_size)).toBe(Buffer.byteLength(mapContent))
expect(rows[0]!.storage_path).toBe(s.storagePath)
expect(rows[0]!.metadata.entryCount).toBe(2)
// 幂等重跑:UNIQUE(site,kind,sha256) 复用既有行,不新增
const r2 = await registerSource(db, makeMapInput())
expect(r2.siteId).toBe(r1.siteId)
expect(r2.snapshots).toHaveLength(1)
expect(r2.snapshots[0]!.id).toBe(s.id)
expect(r2.snapshots[0]!.reused).toBe(true)
const counts = (await db.execute(
`select (select count(*) from source_snapshots where site_id = '${r1.siteId}') as snaps,
(select count(*) from source_connections where site_id = '${r1.siteId}') as conns`,
)) as any
expect(counts[0]).toEqual({ snaps: '1', conns: '1' })
} finally {
await cleanupSiteRows(db, r1.siteId)
}
})
})
})
it('media-map 非法内容(非 JSON / 非数组 / 条目字段缺失或非串):INVALID_MEDIA_MAP 报错且零落库', async () => {
await withTestDb(async (db) => {
await withCtx(async (ctx) => {
const key = `sfreg-${rand()}`
const cases: { label: string; content: string }[] = [
{ label: '非 JSON 文本', content: 'not json at all\n' },
{ label: 'JSON 但非数组', content: '{"sourceUrl": "x"}\n' },
{ label: '数组但条目缺 filePath', content: '[{"sourceUrl": "https://a.example/1.png"}]\n' },
{ label: '数组但条目字段非字符串', content: '[{"sourceUrl": 1, "filePath": "a.png"}]\n' },
]
for (const c of cases) {
const mapFile = join(ctx.work, `bad-map-${cases.indexOf(c)}.json`)
await writeFile(mapFile, c.content, 'utf8')
const err: SourceRegisterError = await registerSource(db, {
siteKey: key,
mediaMapPath: mapFile,
allowedRoots: ctx.allowedRoots,
storageRoot: ctx.storageRoot,
reportRoot: ctx.reportRoot,
}).then(
() => {
throw new Error(`用例「${c.label}」不应成功`)
},
(e: SourceRegisterError) => e,
)
expect(err, c.label).toBeInstanceOf(SourceRegisterError)
expect(err.code, c.label).toBe('INVALID_MEDIA_MAP')
expect(err.message, c.label).toMatch(/media-map/)
// 零落库:site 从未创建
const sites = (await db.execute(`select id from sites where key = '${key}'`)) as any
expect(sites, c.label).toHaveLength(0)
}
})
})
})
it('isUniqueViolation 形状识别:drizzle 包裹(cause.code)/裸 postgres code/其他均正确', () => {
// drizzle 会把 postgres 错误包成 DrizzleQueryError:message 只剩 "Failed query",真 code 在 cause
const drizzleWrapped = Object.assign(new Error('Failed query: insert into sites ...'), {