feat(import): 全链编排与导入报告

Task 6(Plan 3):四家写入器汇流为默认编排管道。

- import.service:注入 events/pipeline 缺席时走真实事件流(runWxrAdapter
  惰性 async generator,mapConfig.locale=site.default_locale 前置事实 8);
  分派严格串行 content→term→media→route(禁 Promise.all);finish 硬序
  term(sync)→content(sync)→media(await)→route(await);counts 由 ⊕ 覆盖
  改为逐 key 求和聚合;新增 stale 回收(同站点 queued/running >24h 一条
  SQL 置 stale);报告新增 paths/productsPath+newsPath、issueGroups 六组、
  sanitize 清洗统计(事件流 changedRows/byDiffType 聚合)、adapterTiming。
- content-writer:NULL-entry 收编(快照行在而 content_entry 空,五列先验
  三分支:无先验新建挂接/同 checksum 挂接 skipped/异 checksum 挂接新版本)。
- route-writer:attachment 降级汇总按去重 sourceId 计(重跑仍必现);
  normalizeListPath 导出;writeAttachmentRoute 返回 written/resolved/blocked。
- 四家写入器 identity 收敛共享基类型 WriterContext =
  Pick<ImportJobContext,'db'|'siteId'|'connectionId'>(type-only import,
  无运行时环依赖)。
- CLI import 增 --products-path/--news-path(前置事实 9,缺省 /products/
  /news/)记入 job 报告。
- 测试:tests/integration/import-full.test.ts 6 例(全链 zh-CN 接线证明
  +published 全 NULL/报告四域计数+sanitize+分组/幂等重跑零写语义/
  无 media 快照域缺失不失败/NULL-entry 收编/stale 回收/REPORT_WRITE_FAILED
  遗留补测);import-job 契约测试钉死 counts 求和(100⊕3=103)。
  全量 323 通过;typecheck/lint 干净。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 20:08:06 +08:00
parent 27a8282367
commit 8003de336b
8 changed files with 856 additions and 89 deletions
+470
View File
@@ -0,0 +1,470 @@
// 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 不落。
// 运行于 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 { createImportJob } 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 })
}
})
})
})
+5 -5
View File
@@ -11,7 +11,7 @@
// 显式 id 不存在或属他站 → SNAPSHOT_NOT_FOUND;站点不存在 → SITE_NOT_FOUND;均零 job 行;
// 5. failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at,
// '[pipeline]' 前缀),重提交同 key 复用失败 job 不重跑;管道抛 ImportError 保留原 code;
// 6. Outcome 契约:ctx.report 累加 ⊕ 返回值(counts 同名键返回值覆盖)→ succeeded_with_issues;
// 6. Outcome 契约:ctx.report 累加 ⊕ 返回值(counts 同名键**求和**——Task 6 编排聚合)→ succeeded_with_issues;
// 7. 复用口径忠实化:reuse 返回只含 job 行锚定字段(快照/版本字段缺席);
// 8. 23505 收敛确定性探针:未提交孪生 job 持有幂等键(真实 PG 锁语义,零代理)。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
@@ -434,7 +434,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
})
})
it('Outcome 契约:ctx.report 累加 ⊕ 返回值(返回值 counts 覆盖同名键),issues 非空 → succeeded_with_issues,计数与问题入报告', async () => {
it('Outcome 契约:ctx.report 累加 ⊕ 返回值按**键求和**(Task 6 编排聚合),issues 非空 → succeeded_with_issues,计数与问题入报告', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
@@ -449,7 +449,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
pipeline: async (jobCtx) => {
// 写入器式累加(Task 6 写入器的使用形态)
jobCtx.report.counts['terms'] = 2
jobCtx.report.counts['contentEntries'] = 999 // 被返回值同名键覆盖
jobCtx.report.counts['contentEntries'] = 100 // 与返回值同名键**求和**(Task 6 钉死:防覆盖安全网)
jobCtx.report.issues.push('自建缺失 term:1(category/uncategorized)')
return {
counts: { contentEntries: 3, routes: 8 },
@@ -468,8 +468,8 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
await readFile(join(ctx.importReportRoot, result.jobId, 'reports', 'import.json'), 'utf8'),
) as { status: string; counts: Record<string, number>; issues: string[] }
expect(report.status).toBe('succeeded_with_issues')
// 汇合规则:counts 按键合并(返回值覆盖累加器同名键)、issues 累加器在前返回值在后
expect(report.counts).toStrictEqual({ terms: 2, contentEntries: 3, routes: 8 })
// 汇合规则:counts 按键**求和**(Task 6 钉死——CLOBBER 废弃)、issues 累加器在前返回值在后
expect(report.counts).toStrictEqual({ terms: 2, contentEntries: 103, routes: 8 })
expect(report.issues).toStrictEqual([
'自建缺失 term:1(category/uncategorized)',
'悬空 attachment 引用:2 条(mini 203/204)',