Files
cms-website/tests/integration/import-media.test.ts
T
浅念 7e6ce91001 fix(import): 媒体工厂守卫与加固——manifest 形态/侧车门控/冲突约束名/窗口边界与冲突测试
评审加固五项:
- manifest 形态守卫:bundle 在场而 metadata.manifest 非数组 → throw(与
  storageRoot 守卫同风格),不与"bundle 合法缺席=空清单"共用静默空 coercion
- 侧车门控:空清单下级②不可达,跳过侧车加载——破损侧车副本不因无用输入
  让整 job 失败
- public_path 冲突 catch 按约束名精确认领(postgres.js 的 constraint_name,
  drizzle 包一层在 cause)——未来加第三唯一索引不误标,其余 23505 原样上抛
- 集成用例:不同 source_url 同旧 uploads 路径 → 后到不覆盖、issue 报告、
  只留一行(mediaRegistered=1)
- 单测边界对拍:'<svg' 恰好收在 1024 字节窗口内(起点 1020)检出;起点
  1021 被截断为 '<sv' → null

310 测试(308+2);typecheck/lint 三绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 18:56:16 +08:00

700 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<AdapterEvent, void, void> {
yield* events
}
/** db.execute 行对象规整(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败) */
function plain(rows: any[]): any[] {
return rows.map((r) => ({ ...r }))
}
/** 事件流收集(mini 全链一饮而尽) */
async function collectMiniEvents(): Promise<AdapterEvent[]> {
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<ImportPipeline>[0]) => Promise<ReturnType<typeof createMediaEventHandler>>,
): 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<void> {
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<string, number>
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(`<?xml version="1.0"?><SVG onload="alert(1)" xmlns="…">`) },
{ name: 'draw.svg', buf: Buffer.from('<svg width="10" height="10"></svg>') },
{ 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('<svg onload="x()"><script>bad()</script></svg>')
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: '<p><img src="https://fixture.example/wp-content/uploads/2026/10/evil.png"/></p>',
},
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 })
}
})
})
})