Files
cms-website/tests/integration/import-media.test.ts
T

700 lines
33 KiB
TypeScript
Raw Normal View History

2026-10-21 18:38:10 +08:00
// 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 })
}
})
})
2026-10-21 18:38:10 +08:00
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 })
}
})
})
})