// 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 { 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 { 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 { return JSON.parse(await readFile(join(w.reportRoot, jobId, 'reports', 'import.json'), 'utf8')) } /** 单值计数便捷 */ async function countOf(db: Db, sql: string): Promise { 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 => { 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 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 //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 { 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 = '' 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('