7e6ce91001
评审加固五项: - 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>
700 lines
33 KiB
TypeScript
700 lines
33 KiB
TypeScript
// 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|