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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user