// Plan 3 Task 4 失败测试(先于实现):媒体登记写入器(三级对拍 + 魔数/隔离 + 悬空引用)。 // 覆盖契约(对照计划 Task 4 与前置事实 7/11、spec §5.5/§11.1/§7.6): // 1. mini 全链三级对拍:①uploads 相对路径清单精确命中(fixture-a)②侧车 source_url→ // file_path→清单实体(fixture-b——清单故意 miss 该路径逼走侧车级)③missing 不插行 // (media_assets sha256 NOT NULL——另测无 bundle 场景);命中行 public_path=旧 uploads // 路径、sha256/byte_size/storage_path 从清单取;media_usages 三行(featured/gallery/ // 正文 img——从 row.attachmentRefs + bodyHtml 提取,revision 锚点经 source_records→ // entry_locales.current_revision_id); // 2. 魔数(集成面):命中文件读实际字节——PNG 过;扩展名声明 MIME 与检测不符 → issue; // SVG 检出(.png 伪装与显式 .svg 双例)→ status='quarantined' + issue(不进产物语义); // 未知签名 → status='unsupported' + issue;quarantined 资产的引用不建 usage; // 3. 悬空引用(mini 203/204):gallery 引用无对应 attachment/清单记录 → mediaDanglingRefs // 计数 + issue(不静默丢弃,§7.6); // 4. 多候选:侧车 file_path 按文件名命中两条清单(同 filename 不同目录)→ 歧义不自动选、 // 不插行、全部候选列 issue(spec §5.5); // 5. 幂等:重跑 media_assets/media_usages 零增行(mediaRegistered=0;悬空引用按流如实再现); // 6. counts 键(media 前缀全局命名空间):mediaRegistered/mediaFromSidecar/mediaMissing/ // mediaQuarantined/mediaDanglingRefs/mediaAmbiguous——与 content-writer 键并存无碰撞。 // 双事件消费设计(实现侧 JSDoc 同步):attachment 行建资产、内容行提取引用;WXR 文档序 // items 先于 attachment——引用先行资产后到,usage 解析在 finish() 统一对 media_assets // 反查(顺序无关)。运行于 globalSetup 临时库(ADR-015);报告根 os.tmpdir,finally 清理。 import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { runWxrAdapter, type AdapterEvent } from '@sf/adapter-wp' import type { UniversalRow } from '@sf/contracts' import type { MediaManifestEntry } from '../../apps/api/src/modules/source/register.service.js' import type { ImportPipeline } from '../../apps/api/src/modules/import/import.service.js' import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' import { createContentEventHandler } from '../../apps/api/src/modules/import/content-writer.js' import { createMediaEventHandler, createMediaEventHandlerFromContext } from '../../apps/api/src/modules/import/media-writer.js' import { registerSource } from '../../apps/api/src/modules/source/register.service.js' import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js' const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const JPEG_SIG = Buffer.from([0xff, 0xd8, 0xff, 0xe0]) /** 伪 PNG/JPEG 体(签名 + 唯一种子载荷——魔数只看头,内容唯一避免跨测试清单撞哈希) */ function pngBytes(seed: string): Buffer { return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')]) } function jpegBytes(seed: string): Buffer { return Buffer.concat([JPEG_SIG, Buffer.from(`jpeg-body:${seed}`, 'latin1')]) } function sha256of(buf: Buffer): string { return createHash('sha256').update(buf).digest('hex') } function manifestEntryOf(path: string, buf: Buffer): MediaManifestEntry { return { path, bytes: buf.length, sha256: sha256of(buf) } } /** 手构事件流的 AsyncIterable 包装(每次调用产新迭代器——重跑各流独立) */ async function* eventsOf(events: AdapterEvent[]): AsyncGenerator { yield* events } /** db.execute 行对象规整(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败) */ function plain(rows: any[]): any[] { return rows.map((r) => ({ ...r })) } /** 事件流收集(mini 全链一饮而尽) */ async function collectMiniEvents(): Promise { const out: AdapterEvent[] = [] for await (const ev of runWxrAdapter(createReadStream(WXR_FIXTURE), { mapConfig: { locale: 'en' } })) { out.push(ev) } return out } /** 手构 attachment row 事件(knownFields.attachmentUrl 是三级对拍的输入锚点) */ function attachmentEvent(spec: { sourceId: string; url: string }): AdapterEvent { const row: UniversalRow = { sourceType: 'wp:attachment', sourceId: spec.sourceId, status: 'inherit', locale: 'en', taxonomyRefs: [], attachmentRefs: [], seo: {}, knownFields: { attachmentUrl: spec.url }, extras: {}, rawLocation: `wxr://item/${spec.sourceId}`, } return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false } } /** Task 6 编排雏形:单消费方拉流,逐事件先 content 后 media 分派(content 先行是 * media-writer usage 锚点的顺序契约——source_records/revision 须已落);两态经 finish 取回 */ function contentPlusMediaPipeline(): ImportPipeline { return contentPlusDispatch(async (ctx) => createMediaEventHandlerFromContext(ctx)) } /** content + 直传 media 组合(手构场景:不经 register 的 media_bundle)——分派形状同上 */ function contentPlusDirectMediaPipeline(identity: { storageRoot: string manifest: MediaManifestEntry[] sidecar: Array<[string, string]> }): ImportPipeline { return contentPlusDispatch(async (ctx) => createMediaEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId, snapshotId: ctx.wxrSnapshot.id, defaultLocale: ctx.defaultLocale, storageRoot: identity.storageRoot, mediaBundleStoragePath: '', mediaManifest: identity.manifest, sidecarMap: new Map(identity.sidecar), }), ) } /** 分派骨架:先 content 后 media(顺序契约),counts 展开合并(media 前缀无碰撞键) */ function contentPlusDispatch( mediaOf: (ctx: Parameters[0]) => Promise>, ): ImportPipeline { return async (ctx) => { const content = createContentEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId, snapshotId: ctx.wxrSnapshot.id, jobId: ctx.jobId, defaultLocale: ctx.defaultLocale, versions: ctx.versions, }) const media = await mediaOf(ctx) for await (const ev of ctx.events) { await content.handleEvent(ev) await media.handleEvent(ev) } const c = content.finish() const m = await media.finish() return { counts: { ...c.counts, ...m.counts }, issues: [...c.issues, ...m.issues] } } } /** 清单/侧车直传管道(手构场景:无内容行——纯 attachment 事件,media 单写入器) */ function directMediaPipeline(identity: { storageRoot: string manifest: MediaManifestEntry[] sidecar: Array<[string, string]> }): ImportPipeline { return async (ctx) => { const handler = createMediaEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId, snapshotId: ctx.wxrSnapshot.id, defaultLocale: ctx.defaultLocale, storageRoot: identity.storageRoot, mediaBundleStoragePath: '', mediaManifest: identity.manifest, sidecarMap: new Map(identity.sidecar), }) for await (const ev of ctx.events) { await handler.handleEvent(ev) } return handler.finish() } } /** 本文件写入的行清场:FK 反序(media_usages → media_assets 先于内容域;两张不可变表走触发器开关) */ async function cleanupSiteRows(db: Db, siteId: string): Promise { await db.execute(`delete from media_usages where site_id = '${siteId}'`) await db.execute(`delete from media_assets where site_id = '${siteId}'`) await db.execute(`delete from content_terms where site_id = '${siteId}'`) await db.execute(`delete from terms where site_id = '${siteId}'`) await db.execute(`delete from taxonomies where site_id = '${siteId}'`) await db.execute(`delete from source_records where site_id = '${siteId}'`) await db.execute(`delete from entry_locales where site_id = '${siteId}'`) await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`) await db.execute(`delete from content_entries where site_id = '${siteId}'`) await db.execute(`delete from import_jobs where site_id = '${siteId}'`) await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`) await db.execute(`delete from source_connections where site_id = '${siteId}'`) await db.execute(`delete from sites where id = '${siteId}'`) } /** mini 登记(各用例公共前置):register → {siteId, connectionId, wxr 快照} */ async function registerMini(db: Db, work: string) { const key = `sfmedia-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, allowedRoots: [FIXTURES_ROOT, work], storageRoot: join(work, 'storage'), reportRoot: join(work, 'artifacts', 'register'), }) return { key, reg, wxr: reg.snapshots.find((s) => s.kind === 'wxr')! } } /** 导入报告读取 */ async function readReport(work: string, jobId: string): Promise<{ status: string counts: Record issues: string[] }> { return JSON.parse(await readFile(join(work, 'artifacts', 'import', jobId, 'reports', 'import.json'), 'utf8')) } describe('媒体登记写入器(三级对拍 + 魔数/隔离)', () => { it('mini 全链三级对拍:①清单精确命中 ②侧车命中 → media_assets(public_path=旧 uploads 路径、sha256/storage_path 从清单取)+ usage 三行 + 悬空 203/204 + counts 全键', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-full-')) try { const events = await collectMiniEvents() // 媒体目录:fixture-a 按旧 uploads 层级放(级①精确命中);fixture-b 用扁平 // GUID 名(清单故意不含 '2026/10/fixture-b.png'——逼走级②侧车) const mediaDir = join(work, 'media') await mkdir(join(mediaDir, '2026/10'), { recursive: true }) const pngA = pngBytes(`fixture-a-${rand()}`) await writeFile(join(mediaDir, '2026/10/fixture-a.png'), pngA) const guidB = `b-${rand()}.png` const pngB = pngBytes(`fixture-b-${rand()}`) await writeFile(join(mediaDir, guidB), pngB) const sidecarPath = join(work, 'media-map.json') await writeFile( sidecarPath, JSON.stringify([ { sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png', filePath: guidB }, ]), ) const key = `sfmedia-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, mediaDir, mediaMapPath: sidecarPath, allowedRoots: [FIXTURES_ROOT, work], storageRoot: join(work, 'storage'), reportRoot: join(work, 'artifacts', 'register'), }) const wxr = reg.snapshots.find((s) => s.kind === 'wxr')! const bundle = reg.snapshots.find((s) => s.kind === 'media_bundle')! try { const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf(events), pipeline: contentPlusMediaPipeline(), }) // 悬空 203/204 → issues 非空 → succeeded_with_issues(§7.6 禁静默) expect(result.status).toBe('succeeded_with_issues') // media_assets 两行:fixture-a 级① / fixture-b 级②;身份列逐项对拍 const assets = (await db.execute(`select source_id, source_url, source_path, storage_path, public_path, sha256, detected_mime, declared_mime, byte_size::int as byte_size, status from media_assets where site_id = '${reg.siteId}' order by source_id`)) as any expect(assets).toHaveLength(2) const a = assets.find((x: any) => x.source_id === '201')! expect(a.source_url).toBe('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png') expect(a.source_path).toBe('2026/10/fixture-a.png') expect(a.public_path).toBe('/wp-content/uploads/2026/10/fixture-a.png') expect(a.sha256).toBe(sha256of(pngA)) expect(a.byte_size).toBe(pngA.length) expect(a.detected_mime).toBe('image/png') expect(a.declared_mime).toBe('image/png') expect(a.status).toBe('ready') // storage_path = 清单实体定位(bundle 副本相对路径 + 清单 path——绝对副本 = storageRoot + 此列) expect(a.storage_path).toBe(`${bundle.storagePath}/2026/10/fixture-a.png`) const b = assets.find((x: any) => x.source_id === '202')! expect(b.source_url).toBe('https://fixture.example/wp-content/uploads/2026/10/fixture-b.png') expect(b.public_path).toBe('/wp-content/uploads/2026/10/fixture-b.png') expect(b.sha256).toBe(sha256of(pngB)) expect(b.storage_path).toBe(`${bundle.storagePath}/${guidB}`) expect(b.status).toBe('ready') // media_usages 三行:101 featured(id 反查)+ 101 正文 img(URL 反查)+ 111 featured const usages = (await db.execute(`select a.source_id as asset, u.field_path, u.source_url from media_usages u join media_assets a on a.id = u.media_asset_id where u.site_id = '${reg.siteId}' order by a.source_id, u.field_path`)) as any expect(plain(usages)).toStrictEqual([ { asset: '201', field_path: 'attachmentRefs.featured', source_url: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', }, { asset: '201', field_path: 'data.bodyHtml.img[0]', source_url: 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', }, { asset: '202', field_path: 'attachmentRefs.featured', source_url: 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png', }, ]) // usage 挂在 content-writer 落的当前修订上(current_revision_id——导入不发布,published NULL) const revCheck = (await db.execute(`select count(*)::int as n from media_usages u join content_revisions cr on cr.id = u.content_revision_id join entry_locales el on el.current_revision_id = cr.id and el.published_revision_id is null where u.site_id = '${reg.siteId}'`)) as any expect(revCheck[0].n).toBe(3) // counts 全键:content 四键 + media 六键(全局命名空间并存无碰撞) const report = await readReport(work, result.jobId) expect(report.status).toBe('succeeded_with_issues') expect(report.counts).toStrictEqual({ contentEntries: 3, revisionsCreated: 3, sourceRecords: 5, skipped: 0, mediaRegistered: 2, mediaFromSidecar: 1, mediaMissing: 0, mediaQuarantined: 0, // mini 全部引用 5 条:featured 201/202 + 正文 img 各解析命中,gallery 203/204 悬空 mediaDanglingRefs: 2, mediaAmbiguous: 0, }) // 悬空引用显式报告:203/204 各一条(gallery 引用无 attachment/清单记录) const dangling = report.issues.filter((i) => i.includes('悬空')) expect(dangling).toHaveLength(2) expect(dangling.some((i) => i.includes('203'))).toBe(true) expect(dangling.some((i) => i.includes('204'))).toBe(true) } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('级③ missing(无 media_bundle 登记):不插 media_assets 行(sha256 NOT NULL)+ mediaMissing + 全部引用悬空如实报告', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-missing-')) try { const events = await collectMiniEvents() const { key, reg, wxr } = await registerMini(db, work) try { const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf(events), pipeline: contentPlusMediaPipeline(), }) expect(result.status).toBe('succeeded_with_issues') // 零资产行:清单缺席 → 级①②全 miss → 级③ missing(不插行——sha256 无来源) expect(((await db.execute(`select count(*)::int as n from media_assets where site_id = '${reg.siteId}'`)) as any)[0].n).toBe(0) expect(((await db.execute(`select count(*)::int as n from media_usages where site_id = '${reg.siteId}'`)) as any)[0].n).toBe(0) const report = await readReport(work, result.jobId) expect(report.counts).toStrictEqual({ contentEntries: 3, revisionsCreated: 3, sourceRecords: 5, skipped: 0, mediaRegistered: 0, mediaFromSidecar: 0, mediaMissing: 2, mediaQuarantined: 0, // 引用全悬空:101 featured+img、111 featured+gallery×2 = 5 mediaDanglingRefs: 5, mediaAmbiguous: 0, }) const missing = report.issues.filter((i) => i.includes('missing')) expect(missing).toHaveLength(2) expect(missing.some((i) => i.includes('fixture-a.png'))).toBe(true) expect(missing.some((i) => i.includes('fixture-b.png'))).toBe(true) expect(report.issues.filter((i) => i.includes('悬空'))).toHaveLength(5) } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('魔数校验:命中文件读实际字节——PNG 过;声明(扩展名推断)与检测不符 → issue;SVG 检出(伪装 .png 与显式 .svg)→ quarantined 不进产物;未知签名 → unsupported', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-magic-')) try { // 五个手构文件(放旧 uploads 层级——级① 相对路径精确命中):正例 PNG / 扩展名谎言 // (.png 实为 JPEG)/ SVG 伪装 .png / 显式 .svg / 无签名 .txt const files: Array<{ name: string; buf: Buffer }> = [ { name: 'ok.png', buf: pngBytes(`ok-${rand()}`) }, { name: 'lie.png', buf: jpegBytes(`lie-${rand()}`) }, { name: 'evil.png', buf: Buffer.from(``) }, { name: 'draw.svg', buf: Buffer.from('') }, { name: 'note.txt', buf: Buffer.from('plain text note, no signature at all') }, ] await mkdir(join(work, '2026/10'), { recursive: true }) for (const f of files) { await writeFile(join(work, '2026/10', f.name), f.buf) } const manifest = files.map((f) => manifestEntryOf(`2026/10/${f.name}`, f.buf)) const events: AdapterEvent[] = files.map((f, i) => attachmentEvent({ sourceId: String(901 + i), url: `https://fixture.example/wp-content/uploads/2026/10/${f.name}` }), ) const { key, reg, wxr } = await registerMini(db, work) try { const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf(events), pipeline: directMediaPipeline({ storageRoot: work, manifest, sidecar: [] }), }) expect(result.status).toBe('succeeded_with_issues') const assets = (await db.execute(`select source_id, detected_mime, declared_mime, status from media_assets where site_id = '${reg.siteId}' order by source_id`)) as any expect(plain(assets)).toStrictEqual([ { source_id: '901', detected_mime: 'image/png', declared_mime: 'image/png', status: 'ready' }, { source_id: '902', detected_mime: 'image/jpeg', declared_mime: 'image/png', status: 'ready' }, { source_id: '903', detected_mime: 'image/svg+xml', declared_mime: 'image/png', status: 'quarantined' }, { source_id: '904', detected_mime: 'image/svg+xml', declared_mime: 'image/svg+xml', status: 'quarantined' }, { source_id: '905', detected_mime: 'application/octet-stream', declared_mime: null, status: 'unsupported' }, ]) const report = await readReport(work, result.jobId) expect(report.counts).toStrictEqual({ mediaRegistered: 5, mediaFromSidecar: 0, mediaMissing: 0, mediaQuarantined: 2, mediaDanglingRefs: 0, mediaAmbiguous: 0, }) // 三类问题各就位:MIME 不符 1(lie.png)+ SVG 隔离 2(evil/draw)+ 未知签名 1(note.txt) const mismatch = report.issues.filter((i) => i.includes('不符')) expect(mismatch).toHaveLength(1) expect(mismatch[0]).toContain('lie.png') const quarantined = report.issues.filter((i) => i.includes('隔离')) expect(quarantined).toHaveLength(2) expect(report.issues.filter((i) => i.includes('未知签名'))).toHaveLength(1) // quarantined/unsupported 资产不进产物语义:即便有引用也不建 usage(本例无内容行,零 usage) expect(((await db.execute(`select count(*)::int as n from media_usages where site_id = '${reg.siteId}'`)) as any)[0].n).toBe(0) } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('quarantined 资产的引用不建 usage(不进产物语义):SVG 资产被正文引用 → 引用关系不落 media_usages', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-quar-')) try { const evil = Buffer.from('') await mkdir(join(work, '2026/10'), { recursive: true }) await writeFile(join(work, '2026/10/evil.png'), evil) const manifest = [manifestEntryOf('2026/10/evil.png', evil)] const attachment = attachmentEvent({ sourceId: '951', url: 'https://fixture.example/wp-content/uploads/2026/10/evil.png', }) const citing: AdapterEvent = { kind: 'row', row: { sourceType: 'wp:post', sourceId: '950', status: 'publish', locale: 'en', taxonomyRefs: [], attachmentRefs: [{ role: 'featured', sourceId: '951' }], seo: {}, knownFields: {}, extras: {}, rawLocation: 'wxr://item/950', bodyHtml: '

', }, issues: [], sanitizeDiffs: [], changed: false, } const { key, reg, wxr } = await registerMini(db, work) try { // 引用行先到、attachment 后到(WXR 文档序)——finish 统一解析 const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf([citing, attachment]), pipeline: contentPlusDirectMediaPipeline({ storageRoot: work, manifest, sidecar: [] }), }) expect(result.status).toBe('succeeded_with_issues') // 资产行存在(登记可审计)但 quarantined;引用解析命中资产(非悬空)但不建 usage const assets = (await db.execute(`select source_id, status from media_assets where site_id = '${reg.siteId}'`)) as any expect(plain(assets)).toStrictEqual([{ source_id: '951', status: 'quarantined' }]) expect(((await db.execute(`select count(*)::int as n from media_usages where site_id = '${reg.siteId}'`)) as any)[0].n).toBe(0) const report = await readReport(work, result.jobId) expect(report.counts.mediaRegistered).toBe(1) expect(report.counts.mediaQuarantined).toBe(1) expect(report.counts.mediaDanglingRefs).toBe(0) expect(report.issues.filter((i) => i.includes('隔离'))).toHaveLength(1) } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('多候选(同 filename 不同目录两条清单):歧义不自动选、不插行、全部候选列 issue', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-ambig-')) try { await mkdir(join(work, 'd1'), { recursive: true }) await mkdir(join(work, 'd2'), { recursive: true }) const f1 = pngBytes(`amb-1-${rand()}`) const f2 = pngBytes(`amb-2-${rand()}`) await writeFile(join(work, 'd1/thing.png'), f1) await writeFile(join(work, 'd2/thing.png'), f2) const manifest = [manifestEntryOf('d1/thing.png', f1), manifestEntryOf('d2/thing.png', f2)] const url = 'https://fixture.example/wp-content/uploads/2024/01/thing.png' const events: AdapterEvent[] = [attachmentEvent({ sourceId: '961', url })] const { key, reg, wxr } = await registerMini(db, work) try { const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf(events), // 侧车 file_path 只给文件名:精确路径 miss → 文件名候选 2 条 → 歧义 pipeline: directMediaPipeline({ storageRoot: work, manifest, sidecar: [[url, 'thing.png']] }), }) expect(result.status).toBe('succeeded_with_issues') expect(((await db.execute(`select count(*)::int as n from media_assets where site_id = '${reg.siteId}'`)) as any)[0].n).toBe(0) const report = await readReport(work, result.jobId) expect(report.counts).toStrictEqual({ mediaRegistered: 0, mediaFromSidecar: 0, mediaMissing: 0, mediaQuarantined: 0, mediaDanglingRefs: 0, mediaAmbiguous: 1, }) const amb = report.issues.filter((i) => i.includes('歧义')) expect(amb).toHaveLength(1) expect(amb[0]).toContain('d1/thing.png') expect(amb[0]).toContain('d2/thing.png') } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('public_path 唯一键冲突:不同 source_url 同旧 uploads 路径 → 后到不覆盖、issue 报告、只留一行', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-pconf-')) try { const dup = pngBytes(`dup-${rand()}`) await mkdir(join(work, '2026/10'), { recursive: true }) await writeFile(join(work, '2026/10/dup.png'), dup) const manifest = [manifestEntryOf('2026/10/dup.png', dup)] // 两 attachment:不同源 URL(不同 host 的 uploads 域)、同一旧 uploads 相对路径 // → 级①命中同一清单实体 → public_path 相同 → 第二行撞 media_assets_public_path_uq const events: AdapterEvent[] = [ attachmentEvent({ sourceId: '971', url: 'https://fixture.example/wp-content/uploads/2026/10/dup.png' }), attachmentEvent({ sourceId: '972', url: 'https://mirror.example/wp-content/uploads/2026/10/dup.png' }), ] const { key, reg, wxr } = await registerMini(db, work) try { const result = await createImportJob(db, { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), events: eventsOf(events), pipeline: directMediaPipeline({ storageRoot: work, manifest, sidecar: [] }), }) expect(result.status).toBe('succeeded_with_issues') // 先到行保留、后到不静默覆盖——同公开路径是真实冲突(同站命名空间不重不解) const assets = (await db.execute(`select source_id, source_url, public_path, status from media_assets where site_id = '${reg.siteId}' order by source_id`)) as any expect(plain(assets)).toStrictEqual([ { source_id: '971', source_url: 'https://fixture.example/wp-content/uploads/2026/10/dup.png', public_path: '/wp-content/uploads/2026/10/dup.png', status: 'ready', }, ]) const report = await readReport(work, result.jobId) expect(report.counts).toStrictEqual({ mediaRegistered: 1, mediaFromSidecar: 0, mediaMissing: 0, mediaQuarantined: 0, mediaDanglingRefs: 0, mediaAmbiguous: 0, }) const conflict = report.issues.filter((i) => i.includes('public_path 冲突')) expect(conflict).toHaveLength(1) expect(conflict[0]).toContain('972') } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) it('幂等重跑:media_assets/media_usages 零增行、mediaRegistered=0;悬空引用按流如实再现', async () => { await withTestDb(async (db) => { const work = await mkdtemp(join(tmpdir(), 'sf-media-idem-')) try { const events = await collectMiniEvents() const mediaDir = join(work, 'media') await mkdir(join(mediaDir, '2026/10'), { recursive: true }) const pngA = pngBytes(`idem-a-${rand()}`) await writeFile(join(mediaDir, '2026/10/fixture-a.png'), pngA) const guidB = `b-${rand()}.png` await writeFile(join(mediaDir, guidB), pngBytes(`idem-b-${rand()}`)) const sidecarPath = join(work, 'media-map.json') await writeFile( sidecarPath, JSON.stringify([ { sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png', filePath: guidB }, ]), ) const key = `sfmedia-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, mediaDir, mediaMapPath: sidecarPath, allowedRoots: [FIXTURES_ROOT, work], storageRoot: join(work, 'storage'), reportRoot: join(work, 'artifacts', 'register'), }) const wxr = reg.snapshots.find((s) => s.kind === 'wxr')! try { const base = { siteKey: key, snapshotId: wxr.id, reportRoot: join(work, 'artifacts', 'import'), pipeline: contentPlusMediaPipeline(), } const first = await createImportJob(db, { ...base, events: eventsOf(events) }) expect(first.status).toBe('succeeded_with_issues') // 同流重跑(换幂等键新 job——钉写入器幂等,不测 job 复用) const again = await createImportJob(db, { ...base, idempotencyKey: `sfmedia-rerun-${rand()}`, events: eventsOf(events), }) expect(again.reused).toBe(false) expect(again.status).toBe('succeeded_with_issues') const after = (await db.execute(`select (select count(*)::int from media_assets where site_id = '${reg.siteId}') as assets, (select count(*)::int from media_usages where site_id = '${reg.siteId}') as usages`)) as any expect(plain(after)[0]).toStrictEqual({ assets: 2, usages: 3 }) // 行面零增;流面计数如实:registered/fromSidecar 归零,悬空引用再现(203/204) const report = await readReport(work, again.jobId) expect(report.counts).toStrictEqual({ contentEntries: 0, revisionsCreated: 0, sourceRecords: 0, skipped: 3, mediaRegistered: 0, mediaFromSidecar: 0, mediaMissing: 0, mediaQuarantined: 0, mediaDanglingRefs: 2, mediaAmbiguous: 0, }) expect(report.issues.filter((i) => i.includes('悬空'))).toHaveLength(2) } finally { await cleanupSiteRows(db, reg.siteId) } } finally { await rm(work, { recursive: true, force: true }) } }) }) })