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 })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
})
|