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:
@@ -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 ...'), {
|
||||
|
||||
Reference in New Issue
Block a user