Files
浅念 a7177afde7 feat(import): HTML 报告与错误封顶;orchestrator 抽取
- report-html.ts:renderImportReportHtml(Plan 2 Task 6 模板纪律——全动态文本
  转义/静态骨架无 script/分组固定序渲染/封顶注记),import.html 与 import.json
  同 try 原子落盘(同 REPORT_WRITE_FAILED 失败口径)
- 报告 issues 封顶(Task 6 推迟项):REPORT_ISSUES_CAP=10k——flat 清单与
  issueGroups 各组同限截断 + issuesTruncated 计数;终态判定(succeeded_with_issues)
  在全量长度上做,截断不吞终态事实;counts/sanitize 始终完整
- orchestrator.ts 抽取(审查触发器 import.service 849 行):orchestratedPipeline/
  mergeOutcomes/sumCounts/trySyncFinish 迁出为纯函数 over ctx;契约类型单源
  import.service.ts 不动;既有测试全绿即证零行为变更
- 测试 +8:单元 7(恶意 payload 转义/结构/分组序/封顶注记/渲染不改输入)+
  集成 1(10_003 条注入:截断计数/JSON 原文保留/HTML 转义/终态判定);333 全绿

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

649 lines
33 KiB
TypeScript
Raw Permalink 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 6 失败测试(先于实现):编排集成 + 导入报告——四家写入器汇流。
// 覆盖契约(对照计划 Task 6 与前置事实 8/11/13、Task 3/5 审查移交清单):
// 1. 全链(register wxr+media+media-map → 默认编排管道·真事件流):content_entries=3 /
// source_records=5 / terms=2+自建 0 / content_terms=1 / media_assets=2 / routes=12;
// published_revision_id 全库 NULL(导入不发布——ADR-010);mapConfig.locale 接线
// (site.default_locale='zh-CN' → 全部 source_records.locale='zh-CN'——locale 只可能
// 来自编排接线,mini 无自带 locale);报告 = 四家 counts 18 键全量 + 清洗统计 +
// 版本块 + issues 分组 + 路径块 + 适配器分段计时;
// 2. 二导幂等(换 key 重跑):全部 counts 归零语义(termsProcessed/mediaDanglingRefs 等
// 按事件计键如实再现)、数据库行数不变、job 仍 succeeded_with_issues
// (attachment 降级汇总 issue 必在——Plan 3 钉死语义:降级是处置事实非写入计数);
// 3. 无 media 快照失败路径:media 域全 missing 报告不失败(空 manifest 语义已备);
// 4. NULL-entry 占位行收编(Task 3 审查移交①):六列命中 content_entry_id NULL 行 →
// UPDATE 挂 entry(不撞 23505、sourceRecords 不虚增);
// 5. stale 回收(义务④):同 site 超 24h 的 queued/running 置 stale,新近 job 不受影响;
// 6. REPORT_WRITE_FAILED(Task 1 遗留):报告写盘失败 → failed 终态、report_path 不落。
// 7. 失败报告部分冲刷(Task 6 审查 Fix 1):事件流中途抛错 → job failed 且报告携带
// eventsDispatched 进度计数 + 部分 content/term 计数(media/route finish 依赖库不调用);
// unsupported/error 事件的 reason/issue 归档 adapter 组(Fix 4——此前被丢弃);
// 8. 主列表路径解析期防线(Fix 3):--products-path/--news-path 含穿越段 →
// INVALID_LIST_PATH 且零 job 行(建行前抛出——建行后再抛会留悬挂 running 行)。
// 9. HTML 报告与 issues 封顶(Task 8 兑现 Task 6 推迟项):import.html 落盘且动态文本
// 全转义;issues 超上限(10k)→ 报告固化面截断 + issuesTruncated 计数;终态判定
// 用全量长度(截断不吞 succeeded_with_issues 事实);对象层(import.json)保留原文。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根 os.tmpdir,finally 清理。
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 type { AdapterEvent } from '@sf/adapter-wp'
import { createImportJob, ImportError } from '../../apps/api/src/modules/import/import.service.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])
/** 伪 PNG 体(魔数校验只看签名——载荷唯一避免跨测试撞哈希) */
function pngBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')])
}
/** 本文件写入的行清场:FK 反序(与 import-routes 同清单;两张不可变表走触发器开关) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
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}'`)
}
interface Work {
work: string
storageRoot: string
reportRoot: string
allowedRoots: string[]
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
return {
work,
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'import'),
allowedRoots: [FIXTURES_ROOT, work],
}
}
/** 全量登记(wxr + 媒体目录 + 侧车)——形态同 import-routes 全链用例;locale 可选注入 */
async function registerFull(
db: Db,
w: Work,
spec: { locale?: string } = {},
): Promise<{ key: string; siteId: string; wxrId: string }> {
const mediaDir = join(w.work, 'media')
await mkdir(join(mediaDir, '2026/10'), { recursive: true })
await writeFile(join(mediaDir, '2026/10/fixture-a.png'), pngBytes(`full-a-${rand()}`))
const guidB = `b-${rand()}.png`
await writeFile(join(mediaDir, guidB), pngBytes(`full-b-${rand()}`))
const sidecarPath = join(w.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 = `sffull-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir,
mediaMapPath: sidecarPath,
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
...(spec.locale === undefined ? {} : { siteDefaults: { defaultLocale: spec.locale } }),
})
return { key, siteId: reg.siteId, wxrId: reg.snapshots.find((s) => s.kind === 'wxr')!.id }
}
/** 仅 wxr 登记(失败路径/收编场景——media_bundle/manual_url_list 缺席) */
async function registerWxrOnly(db: Db, w: Work): Promise<{ key: string; siteId: string; wxrId: string }> {
const key = `sffull-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
})
return { key, siteId: reg.siteId, wxrId: reg.snapshots.find((s) => s.kind === 'wxr')!.id }
}
/** 导入报告读取(默认编排管道的产物断言面) */
async function readReport(w: Work, jobId: string): Promise<any> {
return JSON.parse(await readFile(join(w.reportRoot, jobId, 'reports', 'import.json'), 'utf8'))
}
/** 单值计数便捷 */
async function countOf(db: Db, sql: string): Promise<number> {
const rows = (await db.execute(sql)) as any
return Number(rows[0].n)
}
describe('编排集成 + 导入报告(四家写入器汇流)', () => {
it('全链(真事件流 + 默认编排管道):18 键 counts 全量、published 全 NULL、locale 接线、清洗统计、issues 分组、报告路径/版本/计时块', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-chain')
try {
// locale 接线断言的实证设计(前置事实 8):site 默认 locale 取 'zh-CN'——mini 无
// 自带 locale,行 locale 只可能来自编排接线 mapConfig.locale=site.default_locale
const { key, siteId, wxrId } = await registerFull(db, w, { locale: 'zh-CN' })
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxrId,
reportRoot: w.reportRoot,
})
expect(result.reused).toBe(false)
// 悬空引用(media 203/204)×2 + attachment 降级汇总 ×1 → issues 非空
expect(result.status).toBe('succeeded_with_issues')
// 数据库行数对拍(计划 Task 6 钉死清单)
expect(await countOf(db, `select count(*)::int as n from content_entries where site_id = '${siteId}'`)).toBe(3)
expect(await countOf(db, `select count(*)::int as n from source_records where site_id = '${siteId}'`)).toBe(5)
expect(await countOf(db, `select count(*)::int as n from terms where site_id = '${siteId}'`)).toBe(2)
expect(await countOf(db, `select count(*)::int as n from content_terms where site_id = '${siteId}'`)).toBe(1)
expect(await countOf(db, `select count(*)::int as n from media_assets where site_id = '${siteId}'`)).toBe(2)
expect(await countOf(db, `select count(*)::int as n from route_registry where site_id = '${siteId}'`)).toBe(12)
// published 全库 NULL(导入不发布——ADR-010)+ current 全设(导入产出工作修订)
expect(
await countOf(db, `select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id is not null`),
).toBe(0)
expect(
await countOf(db, `select count(*)::int as n from entry_locales where site_id = '${siteId}' and current_revision_id is not null`),
).toBe(3)
// locale 接线(前置事实 8):全部 source_records 行 locale = site 默认(非空串)
const locales = (await db.execute(`select distinct locale from source_records where site_id = '${siteId}'`)) as any
expect(locales.map((r: any) => r.locale).sort()).toStrictEqual(['zh-CN'])
// attachment 分派规则(前置事实 11):2 条 attachment 行是 record-only 足迹(无 entry)
expect(
await countOf(db, `select count(*)::int as n from source_records where site_id = '${siteId}' and source_type = 'wp:attachment' and content_entry_id is null`),
).toBe(2)
// content_terms:post 101 的 category 引用(is_primary=true——WP 单主分类近似)
// (投影转 Boolean——postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败)
const ct = (await db.execute(`select ct.is_primary, t.slug from content_terms ct
join terms t on t.id = ct.term_id where ct.site_id = '${siteId}'`)) as any
expect(ct.map((r: any) => ({ isPrimary: Boolean(r.is_primary), slug: r.slug }))).toStrictEqual([
{ isPrimary: true, slug: 'company' },
])
// 报告:四家 counts 18 键全量(全局命名空间求和聚合——键前缀互斥)
const report = await readReport(w, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 3,
revisionsCreated: 3,
sourceRecords: 5,
skipped: 0,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
mediaRegistered: 2,
mediaFromSidecar: 1,
mediaMissing: 0,
mediaQuarantined: 0,
mediaDanglingRefs: 2,
mediaAmbiguous: 0,
routesCreated: 12,
routesUpdated: 0,
routesRedirects: 2,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 清洗统计(分派循环收集 row 事件 sanitizeDiffs/changed):mini 仅 post 101 正文
// img 自闭合规范化零 diff 变形(changedRows=1、无 diff 条目——sparse 形态)
expect(report.sanitize).toStrictEqual({ changedRows: 1, byDiffType: {} })
// issues 分组(按写入器前缀):flat issues 保持全量(3 条),分组归位
expect(report.issues).toHaveLength(3)
expect(report.issueGroups).toStrictEqual({
content: [],
term: [],
media: report.issues.filter((i: string) => i.includes('悬空')),
route: report.issues.filter((i: string) => i.includes('attachment 页降级')),
adapter: [],
orchestrator: [],
})
expect(report.issueGroups.media).toHaveLength(2)
expect(report.issueGroups.route).toHaveLength(1)
// 路径块(前置事实 9:CLI 参数来源记入报告——缺省值)+ 版本块 + 分段计时(真流)
expect(report.paths).toStrictEqual({ productsPath: '/products/', newsPath: '/news/' })
expect(report.versions).toStrictEqual({
adapter: report.versions.adapter,
mapping: report.versions.mapping,
sanitizer: report.versions.sanitizer,
parser: report.versions.parser,
})
expect(Object.keys(report.versions).sort()).toStrictEqual(['adapter', 'mapping', 'parser', 'sanitizer'])
expect(typeof report.adapterTiming.parseMs).toBe('number')
expect(typeof report.adapterTiming.sanitizeMs).toBe('number')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('二导幂等(换 key):全部写入计数归零、数据库行数不变、job 仍 succeeded_with_issues(降级汇总 issue 必在)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-idem')
try {
const { key, siteId, wxrId } = await registerFull(db, w)
try {
const base = { siteKey: key, snapshotId: wxrId, reportRoot: w.reportRoot } as const
const first = await createImportJob(db, { ...base })
expect(first.status).toBe('succeeded_with_issues')
const again = await createImportJob(db, { ...base, idempotencyKey: `sffull-rerun-${rand()}` })
expect(again.reused).toBe(false)
expect(again.status).toBe('succeeded_with_issues')
// 写入计数全零语义:按事件计的键(termsProcessed/mediaDanglingRefs)如实再现
const report = await readReport(w, again.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 0,
revisionsCreated: 0,
sourceRecords: 0,
skipped: 3,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 0,
mediaRegistered: 0,
mediaFromSidecar: 0,
mediaMissing: 0,
mediaQuarantined: 0,
mediaDanglingRefs: 2,
mediaAmbiguous: 0,
routesCreated: 0,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 数据库行数不变(幂等:零新行零修订零更新)
expect(await countOf(db, `select count(*)::int as n from content_entries where site_id = '${siteId}'`)).toBe(3)
expect(await countOf(db, `select count(*)::int as n from content_revisions where site_id = '${siteId}'`)).toBe(3)
expect(await countOf(db, `select count(*)::int as n from source_records where site_id = '${siteId}'`)).toBe(5)
expect(await countOf(db, `select count(*)::int as n from terms where site_id = '${siteId}'`)).toBe(2)
expect(await countOf(db, `select count(*)::int as n from content_terms where site_id = '${siteId}'`)).toBe(1)
expect(await countOf(db, `select count(*)::int as n from media_assets where site_id = '${siteId}'`)).toBe(2)
expect(await countOf(db, `select count(*)::int as n from media_usages where site_id = '${siteId}'`)).toBe(3)
expect(await countOf(db, `select count(*)::int as n from route_registry where site_id = '${siteId}'`)).toBe(12)
// 降级汇总 issue 必在(Plan 3 钉死:降级是处置事实,重跑如实再现)+ 悬空 2 条
const downgrade = report.issues.filter((i: string) => i.includes('attachment 页降级'))
expect(downgrade).toHaveLength(1)
expect(downgrade[0]).toContain('2 条')
expect(report.issues.filter((i: string) => i.includes('悬空'))).toHaveLength(2)
expect(report.issues).toHaveLength(3)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('无 media 快照(只 wxr):media 域全 missing 报告不失败(空 manifest 语义)+ 主列表路径参数入报告', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-nomedia')
try {
const { key, siteId, wxrId } = await registerWxrOnly(db, w)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxrId,
reportRoot: w.reportRoot,
productsPath: '/shop/',
})
expect(result.status).toBe('succeeded_with_issues')
// 空清单语义:2 attachment 全 missing(不插行)、5 引用全悬空、路由 8 行
// (3 站点页 + 3 内容 + 2 分类——301/asset 无资产可指)
expect(await countOf(db, `select count(*)::int as n from media_assets where site_id = '${siteId}'`)).toBe(0)
expect(await countOf(db, `select count(*)::int as n from route_registry where site_id = '${siteId}'`)).toBe(8)
expect(
await countOf(db, `select count(*)::int as n from route_registry where site_id = '${siteId}' and route_path = '/shop/'`),
).toBe(1)
expect(
await countOf(db, `select count(*)::int as n from route_registry where site_id = '${siteId}' and route_path = '/products/'`),
).toBe(0)
const report = await readReport(w, result.jobId)
expect(report.counts.mediaMissing).toBe(2)
expect(report.counts.mediaRegistered).toBe(0)
expect(report.counts.mediaDanglingRefs).toBe(5)
expect(report.counts.routesCreated).toBe(8)
expect(report.paths).toStrictEqual({ productsPath: '/shop/', newsPath: '/news/' })
// attachment 301 目标缺席逐条报告(§7.6)+ missing/悬空——全部在 issues
expect(report.issues.filter((i: string) => i.includes('301 目标缺席'))).toHaveLength(2)
expect(report.issues.filter((i: string) => i.includes('missing'))).toHaveLength(2)
expect(report.issues.filter((i: string) => i.includes('悬空'))).toHaveLength(5)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('NULL-entry 占位行收编(Task 3 审查移交①):六列命中 content_entry_id NULL 行 → UPDATE 挂 entry、不撞 23505、sourceRecords 不虚增', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-adopt')
try {
const { key, siteId, wxrId } = await registerWxrOnly(db, w)
try {
// 外源占位行(seed 模拟:六列身份 = wp:post/101 本快照、entry NULL)
const seedJob = (await db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status,
mapping_version, idempotency_key, attempt) values (gen_random_uuid(), '${siteId}', '${wxrId}',
'succeeded', 'seed', 'seed-${rand()}', 1) returning id`)) as any
const connRow = (await db.execute(`select source_connection_id from source_snapshots where id = '${wxrId}'`)) as any
const seeded = (await db.execute(`insert into source_records (id, site_id, source_connection_id,
source_snapshot_id, import_job_id, source_type, source_id, locale, raw_location, parse_status,
content_entry_id) values (gen_random_uuid(), '${siteId}', '${connRow[0].source_connection_id}',
'${wxrId}', '${seedJob[0].id}', 'wp:post', '101', 'en', 'wxr://item/101', 'imported', NULL)
returning id`)) as any
const result = await createImportJob(db, { siteKey: key, snapshotId: wxrId, reportRoot: w.reportRoot })
expect(result.status).toBe('succeeded_with_issues')
// 占位行被收编:挂 entry + checksum 前移(非新建行——sourceRecords=4 不是 5)
const adopted = (await db.execute(`select content_entry_id, normalized_checksum from source_records
where id = '${seeded[0].id}'`)) as any
expect(adopted[0].content_entry_id).not.toBeNull()
expect(adopted[0].normalized_checksum).not.toBeNull()
const el = (await db.execute(`select current_revision_id, published_revision_id from entry_locales
where site_id = '${siteId}' and content_entry_id = '${adopted[0].content_entry_id}'`)) as any
expect(el[0].current_revision_id).not.toBeNull()
expect(el[0].published_revision_id).toBeNull()
const report = await readReport(w, result.jobId)
expect(report.counts.contentEntries).toBe(3)
expect(report.counts.revisionsCreated).toBe(3)
expect(report.counts.sourceRecords).toBe(4) // 101 收编非新建(2 attachment + 111/121)
expect(report.counts.skipped).toBe(0)
// 收编行照常参与 term 关联与路由(101 的 entry 锚点就位)
expect(report.counts.contentTermsCreated).toBe(1)
expect(report.counts.routesCreated).toBe(8)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('stale 回收(义务④):同 site 超 24h 的 queued/running 置 stale;新近 job 不受影响;新 job 正常推进', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-stale')
try {
const { key, siteId, wxrId } = await registerWxrOnly(db, w)
try {
const oldH = 25
const freshH = 1
const seed = async (status: string, hoursAgo: number, idem: string): Promise<string> => {
const rows = (await db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status,
mapping_version, idempotency_key, attempt, started_at, created_at) values (gen_random_uuid(),
'${siteId}', '${wxrId}', '${status}', 'seed', '${idem}', 1,
now() - interval '${hoursAgo} hours', now() - interval '${hoursAgo} hours') returning id`)) as any
return rows[0].id
}
const oldRunning = await seed('running', oldH, `stale-run-${rand()}`)
const oldQueued = await seed('queued', oldH + 2, `stale-q-${rand()}`)
const freshRunning = await seed('running', freshH, `stale-fresh-${rand()}`)
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxrId,
reportRoot: w.reportRoot,
pipeline: async () => ({ counts: {}, issues: [] }),
})
expect(result.status).toBe('succeeded')
const st = (rows: string[]) =>
(db.execute(`select status, finished_at from import_jobs where id in (${rows.map((r) => `'${r}'`).join(',')})`) as any) as Promise<any>
const oldRows = await st([oldRunning, oldQueued])
for (const r of oldRows) {
expect(r.status).toBe('stale')
expect(r.finished_at).not.toBeNull()
}
const freshRows = await st([freshRunning])
expect(freshRows[0].status).toBe('running') // 未超时不动(心跳列决策留 ADR-017)
expect(freshRows[0].finished_at).toBeNull()
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('REPORT_WRITE_FAILED(Task 1 遗留):报告目录不可建 → failed 终态、error_code 落列、report_path 不落', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-rwfail')
try {
const { key, siteId, wxrId } = await registerWxrOnly(db, w)
try {
// 用普通文件占住报告根:mkdir <file>/<jobId>/reports 必败(ENOTDIR/EEXIST)
const blocker = join(w.work, 'blocker')
await writeFile(blocker, 'not a directory')
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxrId,
reportRoot: blocker,
pipeline: async () => ({ counts: { ok: 1 }, issues: [] }),
})
expect(result.status).toBe('failed')
expect(result.reportPath).toBeNull()
const job = (await db.execute(`select status, error_code, error_summary, report_path, finished_at
from import_jobs where id = '${result.jobId}'`)) as any
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('REPORT_WRITE_FAILED')
expect(String(job[0].error_summary)).toMatch(/^\[report\] /)
expect(job[0].report_path).toBeNull() // 文件不存在不悬挂路径
expect(job[0].finished_at).not.toBeNull()
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('失败报告部分冲刷(Fix 1):事件流中途抛错 → failed + 报告含 eventsDispatched 与部分 content/term 计数;unsupported/error reason 归档 adapter 组(Fix 4)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-flush')
try {
const { key, siteId } = await registerWxrOnly(db, w)
try {
// 手构流 + 真编排(注入 events 而不注入 pipeline):3 个事件成功分派后,
// 第 4 次拉取抛错——分派循环内炸(非写入器抛),编排器须冲刷部分产出后重抛
async function* bomb(): AsyncGenerator<AdapterEvent, void, void> {
yield {
kind: 'row',
row: {
sourceType: 'wp:post',
sourceId: '301',
status: 'publish',
locale: 'en',
taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }],
attachmentRefs: [],
seo: {},
knownFields: {},
extras: {},
rawLocation: 'wxr://item/301',
},
issues: [],
sanitizeDiffs: [],
changed: false,
}
yield {
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/7',
postId: '68',
}
yield { kind: 'error', sourceType: 'wp:post', issue: 'post_id 缺失或非法', rawLocation: 'wxr://item/9' }
throw new Error('BOOM: 事件流在第 4 次拉取时爆炸')
}
const result = await createImportJob(db, { siteKey: key, reportRoot: w.reportRoot, events: bomb() })
expect(result.status).toBe('failed')
expect(result.reportPath).not.toBeNull()
const job = (await db.execute(`select error_code, error_summary, report_path, finished_at
from import_jobs where id = '${result.jobId}'`)) as any
expect(job[0].error_code).toBe('PIPELINE_FAILED')
expect(String(job[0].error_summary)).toMatch(/^\[pipeline\] /)
expect(job[0].report_path).not.toBeNull() // 失败也落报告(部分产出可审计)
expect(job[0].finished_at).not.toBeNull()
const report = await readReport(w, result.jobId)
expect(report.status).toBe('failed')
expect(report.error.code).toBe('PIPELINE_FAILED')
// 部分冲刷面:成功分派的 3 事件的 content/term 计数 + 进度计数 eventsDispatched
//(media/route finish 依赖库与先序产物——失败路径不调用,其键缺席)
expect(report.counts).toStrictEqual({
eventsDispatched: 3,
contentEntries: 1,
revisionsCreated: 1,
sourceRecords: 3,
skipped: 0,
unsupportedRecords: 1,
failedRecords: 1,
termsProcessed: 0,
autoCreatedTerms: 1,
contentTermsCreated: 1,
})
// unsupported/error 的 reason/issue 归档 adapter 组(Fix 4——此前被静默丢弃)
expect(report.issueGroups.adapter).toHaveLength(2)
expect(report.issueGroups.adapter[0]).toContain('不支持对象')
expect(report.issueGroups.adapter[0]).toContain('post_type 未配置映射')
expect(report.issueGroups.adapter[1]).toContain('适配器错误')
expect(report.issueGroups.adapter[1]).toContain('post_id 缺失或非法')
// 冲刷的 issue 同时在 flat 清单;content/term/media/route 组空(本流无其问题)
expect(report.issues).toHaveLength(2)
expect(report.issueGroups).toStrictEqual({
content: [],
term: [],
media: [],
route: [],
adapter: report.issues,
orchestrator: [],
})
// 落库审计:已分派事件的部分写入保留(各写入器单语句已提交,失败不回滚)
expect(await countOf(db, `select count(*)::int as n from content_entries where site_id = '${siteId}'`)).toBe(1)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('主列表路径解析期防线(Fix 3):--products-path/--news-path 含穿越段 → INVALID_LIST_PATH、零 job 行(建行前抛出)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-path')
try {
const { key, siteId } = await registerWxrOnly(db, w)
try {
const cases: Array<[string, { productsPath?: string; newsPath?: string }]> = [
['productsPath 穿越段', { productsPath: '/../x/' }],
['newsPath 穿越段', { newsPath: '/news/../../etc/' }],
]
for (const [label, paths] of cases) {
const err: ImportError = await createImportJob(db, {
siteKey: key,
reportRoot: w.reportRoot,
...paths,
}).then(
() => {
throw new Error(`用例「${label}」不应成功`)
},
(e: ImportError) => e,
)
expect(err, label).toBeInstanceOf(ImportError)
expect(err.code, label).toBe('INVALID_LIST_PATH')
expect(err.message, label).toContain('..') // 消息携带原始入参(操作者可定位)
}
// 零 job 行:解析期失败(建行前)——建行后再抛会留悬挂 running 行
expect(await countOf(db, `select count(*)::int as n from import_jobs where site_id = '${siteId}'`)).toBe(0)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('HTML 报告与 issues 封顶(Task 8):import.html 落盘且转义;issues 超上限 → 截断 + issuesTruncated、终态判定用全量长度、JSON 对象层留原文', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-full-html')
try {
const { key, siteId } = await registerWxrOnly(db, w)
try {
// 恶意 payload(清洗前 excerpt 形态)+ 超上限 issues(10_003 条)
const payload = '<script src="https://evil.example/x.js"></script>'
const total = 10_003
const issues = Array.from({ length: total }, (_, i) =>
i === 0 ? `${payload}悬空 attachment 引用:${i}` : `悬空 attachment 引用:${i}`,
)
const result = await createImportJob(db, {
siteKey: key,
reportRoot: w.reportRoot,
pipeline: async () => ({ counts: { contentEntries: 0 }, issues, issueGroups: { media: issues } }),
})
// 终态判定在截断前的全量长度上做(截断是报告固化层语义,不吞终态事实)
expect(result.status).toBe('succeeded_with_issues')
const report = await readReport(w, result.jobId)
expect(report.issues).toHaveLength(10_000)
expect(report.issuesTruncated).toBe(3)
expect(report.issueGroups.media).toHaveLength(10_000) // 各组同限截断
expect(report.issues[0]).toContain('<script') // JSON 对象层保留原文(转义只在渲染层)
// HTML:落盘(与 import.json 同目录)+ 全动态文本转义 + 封顶注记
const html = await readFile(join(w.reportRoot, result.jobId, 'reports', 'import.html'), 'utf8')
expect(html).not.toContain('<script') // 骨架无 script,出现即 XSS 面
expect(html).toContain('&lt;script src=&quot;https://evil.example/x.js&quot;&gt;')
expect(html).toContain('(另有 3 条超出固化上限,仅计数)')
expect(html).toContain('悬空 attachment 引用:0')
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
})