8003de336b
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>
471 lines
24 KiB
TypeScript
471 lines
24 KiB
TypeScript
// 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|