Files
cms-website/packages/adapter-wordpress/test/adapter.test.ts
T
浅念 0e8b2b5356 feat(import): job 骨架与生命周期
Plan 3 Task 1:sf import 命令骨架 + import_jobs 生命周期(空管道,Task 6 接真实管道)。

- import.service.ts(零装饰器):createImportJob——站点/快照解析(缺省按 (connection,
  kind='wxr') 选最新;三快照绑定按前置事实 13 记入报告,job 行不扩列)、幂等建行
  (默认 key=site+wxr快照+三语义版本;重复提交复用既有 job 不重跑,并发撞唯一约束
  重查收敛)、状态推进 queued→running→succeeded/failed(attempt/started_at/finished_at/
  error_code/error_summary)、报告落 artifacts/import/<jobId>/reports/import.json
  (全四版本块:adapter/mapping/sanitizer/parser;job 行落 mapping_version)
- 可预期错误带 code:SITE_NOT_FOUND / SNAPSHOT_NOT_FOUND / NO_WXR_SNAPSHOT /
  PIPELINE_FAILED——解析期失败零 job 行
- adapter 事件加法式补 postId(前置事实 12):unsupported/error 事件可选携带
  String(postId),缺失时键缺席不硬造 source_id;row 事件与报告聚合零影响、不触发版本 bump
- canonicalJson 提取至 apps/api/src/common/canonical.ts 共享导出(前置事实 6),
  register 侧改 import 零行为变更(既有清单哈希测试守护)
- sf CLI + import 命令(--site/--snapshot/--key/--connection),输出 job 摘要与版本块
- 测试:tests/integration/import-job.test.ts(5 例:生命周期观察/最新快照解析/幂等复用
  与并发收敛/可预期失败零落行/failed 路径)+ adapter postId 3 例 + canonical 单测 6 例
  (265→279 全绿);typecheck 四段链/lint 绿;临时库 CLI 冒烟(register→import→复用)通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 15:30:42 +08:00

567 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Task 6 失败测试(先于实现):适配器全链组装(parseWxrStream → mapItem → sanitizeBodyHtml)
// + 导入前报告聚合(spec §7.7 对齐)。
//
// 全链事件契约:term 旁路直通(terms 是表数据不是内容行,不经映射);item 经映射三分支
// (row/unsupported/error),row 分支在组装层追加清洗(bodyHtml 为空跳过清洗零 diff)。
// changed 语义(Task 5 审查移交钉死):`html !== input` 字符串比较——diffs.length===0
// 不等于未变更(img 自闭合规范化等零 diff 等价变形)。
//
// 手构样本经真实 parseWxrStream 喂入(组装层的输入契约就是 WXR 字节流,不走 mapItem
// 直调捷径——端到端才是本任务的被测对象)。
import { createReadStream } from 'node:fs'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { isUniversalRow } from '@sf/contracts'
import { WxrParseError } from '../src/parse/wxr-stream.js'
import {
runWxrAdapter,
type AdapterEvent,
type AdapterTimings,
type RowResult,
} from '../src/adapter.js'
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js'
import {
ADAPTER_VERSION,
MAPPING_VERSION,
PARSER_VERSION,
SANITIZER_VERSION,
} from '../src/versions.js'
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** 从字符串构造单 chunk Readable(wxr-stream 测试同款) */
function fromXml(inner: string): Readable {
const s = new PassThrough()
s.end(inner)
return s
}
/** 按字节切块喂入(多 chunk 路径;wxr-stream 测试同款,7 字节必拆多字节字符) */
function fromXmlChunked(xml: string, size = 7): Readable {
return Readable.from(
(function* () {
const buf = Buffer.from(xml, 'utf8')
for (let i = 0; i < buf.length; i += size) yield buf.subarray(i, i + size)
})(),
)
}
/** 模拟文件读取中途 IO 失败:交付一个 chunk 后 destroy(err)(code/path 形态同 fs 错误) */
function ioErrorStream(prefix: string, code: string, path: string): Readable {
let sent = false
return new Readable({
read() {
if (sent) return
sent = true
this.push(prefix)
queueMicrotask(() => {
this.destroy(
Object.assign(new Error(`${code}: no such file or directory, open '${path}'`), {
code,
path,
}),
)
})
},
})
}
/** WXR 骨架(命名空间照 mini.xml;saxes xmlns:false 只认字面限定名,声明仅为形态完整) */
function wxrDoc(inner: string): string {
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<rss version="2.0" xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"' +
' xmlns:content="http://purl.org/rss/1.0/modules/content/"' +
' xmlns:dc="http://purl.org/dc/elements/1.0/" xmlns:wp="http://wordpress.org/export/1.2/">' +
`<channel>${inner}</channel></rss>`
)
}
/** 手构 item XML(缺省为最平凡合法值;postId:null = 整个元素缺席 → 解析为 null) */
function itemXml(fields: {
postId?: number | null
postType?: string
status?: string
content?: string
title?: string
metas?: ReadonlyArray<{ key: string; value: string }>
}): string {
const parts: string[] = ['<item>']
parts.push(`<title><![CDATA[${fields.title ?? 'Handmade'}]]></title>`)
if (fields.postId !== null) parts.push(`<wp:post_id>${fields.postId ?? 1}</wp:post_id>`)
parts.push(`<wp:post_type><![CDATA[${fields.postType ?? 'post'}]]></wp:post_type>`)
parts.push(`<wp:status><![CDATA[${fields.status ?? 'publish'}]]></wp:status>`)
if (fields.content !== undefined) {
parts.push(`<content:encoded><![CDATA[${fields.content}]]></content:encoded>`)
}
for (const m of fields.metas ?? []) {
parts.push(
`<wp:postmeta><wp:meta_key><![CDATA[${m.key}]]></wp:meta_key>` +
`<wp:meta_value><![CDATA[${m.value}]]></wp:meta_value></wp:postmeta>`,
)
}
parts.push('</item>')
return parts.join('')
}
async function collect(gen: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[]> {
const out: AdapterEvent[] = []
for await (const ev of gen) out.push(ev)
return out
}
/** 概要签名(按文档序):row:<sourceType>:<sourceId> / term:<id>:<taxonomy> / unsupported / error */
function signature(events: AdapterEvent[]): string[] {
return events.map((ev) => {
switch (ev.kind) {
case 'row':
return `row:${ev.row.sourceType}:${ev.row.sourceId}`
case 'term':
return `term:${ev.term.termId}:${ev.term.taxonomy}`
case 'unsupported':
return `unsupported:${ev.sourceType}`
case 'error':
return `error:${ev.rawLocation}`
}
})
}
function rowEvents(events: AdapterEvent[]): RowResult[] {
return events.filter((ev): ev is RowResult => ev.kind === 'row')
}
/** 手构单 item 样本的唯一 row 事件(非唯一即失败——noUncheckedIndexedAccess 防线) */
function soleRow(events: AdapterEvent[]): RowResult {
const rows = rowEvents(events)
if (rows.length !== 1) throw new Error(`期望唯一 row 事件,实际 ${rows.length} 条`)
return rows[0] as RowResult
}
/** 迭代到错误或完结,保留已交付事件(致命解析路径断言用) */
async function collectUntilError(
gen: AsyncGenerator<AdapterEvent>,
): Promise<{ events: AdapterEvent[]; error: unknown }> {
const events: AdapterEvent[] = []
try {
for await (const ev of gen) events.push(ev)
return { events, error: null }
} catch (e) {
return { events, error: e }
}
}
describe('runWxrAdapter:mini.xml 全链', () => {
it('产出 5 row(post/product/page + 2 attachment)+ 2 term 旁路直通,0 unsupported / 0 error', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
expect(signature(events)).toStrictEqual([
'row:wp:post:101',
'row:wp:product:111',
'row:wp:page:121',
'term:31:category',
'term:32:product_cat',
'row:wp:attachment:201',
'row:wp:attachment:202',
])
})
it('post(101) 端到端:bodyHtml 已清洗(内链/图 URL 原样,img 自闭合规范化),changed=true', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = rowEvents(events).find((r) => r.row.sourceId === '101')
expect(ev).toBeDefined()
expect(ev?.row.bodyHtml).toBe(
'<p>Body with <a href="/products/fixture-product/">link</a> and ' +
'<img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png" /></p>',
)
// URL 原样(Task 5 移交:路径重写属阶段 3,本层只清洗不搬家)
expect(ev?.row.bodyHtml).toContain('href="/products/fixture-product/"')
expect(ev?.row.bodyHtml).toContain('src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"')
expect(ev?.changed).toBe(true) // img 自闭合补空格:零 diff 的等价变形也是变更
expect(ev?.sanitizeDiffs).toStrictEqual([]) // mini 正文全白名单内
expect(isUniversalRow(ev?.row)).toBe(true)
})
it('attachment(201/202):bodyHtml 缺席 → 跳过清洗零 diff、changed=false', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const sourceId of ['201', '202']) {
const ev = rowEvents(events).find((r) => r.row.sourceId === sourceId)
expect(ev).toBeDefined()
expect(ev?.row.sourceType).toBe('wp:attachment')
expect('bodyHtml' in (ev?.row ?? {})).toBe(false)
expect(ev?.sanitizeDiffs).toStrictEqual([])
expect(ev?.changed).toBe(false)
}
})
it('term 事件旁路直通:完整 WxrTerm(不经映射、不产 row)', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const terms = events.filter((ev) => ev.kind === 'term')
expect(terms).toHaveLength(2)
expect(terms[0]).toStrictEqual({
kind: 'term',
term: {
rawLocation: 'wxr://term/1',
termId: 31,
taxonomy: 'category',
slug: 'company',
name: 'Company',
},
})
})
it('纯白名单正文(<p>Product body</p>):changed=false、零 diff(mini product/page 同证)', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const sourceId of ['111', '121']) {
const ev = rowEvents(events).find((r) => r.row.sourceId === sourceId)
expect(ev?.changed).toBe(false)
expect(ev?.sanitizeDiffs).toStrictEqual([])
}
})
})
describe('RowResult 结构与 changed 语义(Task 5 移交钉死)', () => {
it('RowResult = {kind,row,issues,sanitizeDiffs,changed} 五键,issues 为 mapItem+契约复检并集', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = rowEvents(events).find((r) => r.row.sourceId === '101')
expect(ev).toBeDefined()
expect(Object.keys(ev as object).sort()).toStrictEqual([
'changed', 'issues', 'kind', 'row', 'sanitizeDiffs',
])
expect((ev as RowResult).issues).toStrictEqual([]) // mini 101:无映射 issue、契约复检通过
})
it('changed ≠ diffs.length:img 自闭合规范化零 diff 但 changed=true', async () => {
const events = await collect(
runWxrAdapter(
fromXml(wxrDoc(itemXml({ postId: 301, content: '<img src="https://ok.example/a.png"/>' }))),
{ mapConfig: { locale: 'zh-CN' } },
),
)
const ev = soleRow(events)
expect(ev.sanitizeDiffs).toStrictEqual([]) // 白名单内,零拒绝
expect(ev.row.bodyHtml).toBe('<img src="https://ok.example/a.png" />') // 等价变形
expect(ev.changed).toBe(true) // html !== input
})
})
describe('runWxrAdapterReport:报告结构(spec §7.7 对齐)', () => {
it('mini.xml:版本块居首 + 按类型计数 + 清洗统计 + 空错误清单 + 分段耗时', async () => {
const report = await runWxrAdapterReport(createReadStream(FIXTURE), {
mapConfig: { locale: 'zh-CN' },
})
// 首条版本块(spec §7.7:Snapshot 与三版本;本层无 Snapshot 语境,恒居首键位)
expect(Object.keys(report)[0]).toBe('versions')
expect(report.versions).toStrictEqual({
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
sanitizer: SANITIZER_VERSION,
parser: expect.any(String),
})
// 按类型计数:changed → Created(将产新修订);无变化 → Skipped(无变化)
expect(report.counts).toStrictEqual({
'wp:post': { created: 1, skipped: 0, failed: 0, unsupported: 0 },
'wp:product': { created: 0, skipped: 1, failed: 0, unsupported: 0 },
'wp:page': { created: 0, skipped: 1, failed: 0, unsupported: 0 },
'wp:attachment': { created: 0, skipped: 2, failed: 0, unsupported: 0 },
})
expect(report.totals).toStrictEqual({ rows: 5, terms: 2, unsupported: 0, failed: 0 })
// 清洗统计:mini 零拒绝;changed 条目数=1(post 101 的 img 规范化)
expect(report.sanitize).toStrictEqual({
byDiffType: { 'removed-tag': 0, 'removed-attr': 0 },
changedRows: 1,
})
expect(report.rowErrors).toStrictEqual([]) // wppop 解析成功(111)不产生行级错误
expect(report.fatalError).toBeNull()
// 耗时分段(Task 5 移交:清洗耗时与解析分开计时)
expect(report.timing.parseMs).toBeGreaterThanOrEqual(0)
expect(report.timing.sanitizeMs).toBeGreaterThanOrEqual(0)
expect(report.timing.totalMs).toBeGreaterThanOrEqual(report.timing.parseMs)
})
it('版本常量语义锚定:三常量非空串、相互独立(未来 bump 不互绑)', async () => {
expect(ADAPTER_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(MAPPING_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(SANITIZER_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
})
})
describe('恶意样本:清洗记账 + 报告 removed-tag 计数 + HTML 渲染转义', () => {
it('bodyHtml 含 <script>:row 照常产出、sanitizeDiffs 记账、报告 removed-tag=1', async () => {
const xml = wxrDoc(
itemXml({ postId: 302, content: '<p>ok</p><script>alert(1)</script>' }),
)
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = soleRow(events)
expect(ev.row.bodyHtml).toBe('<p>ok</p>') // script 内文随 nonTextTags 语义一并丢弃
expect(ev.changed).toBe(true)
expect(ev.sanitizeDiffs).toStrictEqual([
{ type: 'removed-tag', tag: 'script', excerpt: '<script>' },
])
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.sanitize.byDiffType['removed-tag']).toBe(1)
expect(report.counts['wp:post']).toStrictEqual({ created: 1, skipped: 0, failed: 0, unsupported: 0 })
})
it('行级错误 excerpt 原样进报告对象;renderReportHtml 对 <>&" 全转义', async () => {
const xml = wxrDoc(
itemXml({ postId: 303, content: '<script src="x">evil()</script><p>ok</p>' }),
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
// 报告对象内 excerpt 未转义(JSON 语义层保留原文,转义是渲染层职责)
const entry = report.rowErrors.find((e) => e.excerpt !== undefined)
expect(entry).toMatchObject({ rawLocation: 'wxr://item/1', excerpt: '<script src="x">' })
const html = renderReportHtml(report)
expect(html).toContain('&lt;script src=&quot;x&quot;&gt;')
expect(html).not.toContain('<script src="x">')
expect(html).not.toContain('<script') // 静态骨架无 script 标签——动态文本亦无裸漏
// message 同经转义函数(清洗差异 message 携带 <tag> 形态)
expect(html).not.toContain('<script>')
})
it('renderReportHtml:空报告(零行零错误)也可渲染,无 undefined/null 字面量', () => {
const empty: WxrAdapterReport = {
versions: { adapter: ADAPTER_VERSION, mapping: MAPPING_VERSION, sanitizer: SANITIZER_VERSION, parser: 'saxes 6.0.0' },
counts: {},
totals: { rows: 0, terms: 0, unsupported: 0, failed: 0 },
sanitize: { byDiffType: { 'removed-tag': 0, 'removed-attr': 0 }, changedRows: 0 },
rowErrors: [],
rowErrorsTruncated: 0,
timing: { parseMs: 0, sanitizeMs: 0, totalMs: 0 },
fatalError: null,
}
const html = renderReportHtml(empty)
expect(html).toContain('版本')
expect(html).not.toContain('undefined')
expect(html).not.toContain('null')
})
})
describe('unsupported / error 汇聚', () => {
const XML = wxrDoc(
itemXml({ postId: 401, postType: 'nav_menu_item' }) + // 未配置路由 → unsupported
itemXml({ postId: null, postType: 'post' }), // post_id 缺失 → error
)
it('事件流:unsupported(wp:nav_menu_item)与 error(post_id 缺失)逐条产出', async () => {
const events = await collect(runWxrAdapter(fromXml(XML), { mapConfig: { locale: 'zh-CN' } }))
expect(signature(events)).toStrictEqual(['unsupported:wp:nav_menu_item', 'error:wxr://item/2'])
expect(events[0]).toMatchObject({
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/1',
})
expect(events[1]).toMatchObject({
kind: 'error',
issue: 'post_id 缺失或非法',
rawLocation: 'wxr://item/2',
})
})
it('报告:对应类型计数 + 行级错误(rawLocation 定位)', async () => {
const report = await runWxrAdapterReport(fromXml(XML), { mapConfig: { locale: 'zh-CN' } })
expect(report.counts).toStrictEqual({
'wp:nav_menu_item': { created: 0, skipped: 0, failed: 0, unsupported: 1 },
'wp:post': { created: 0, skipped: 0, failed: 1, unsupported: 0 },
})
expect(report.totals).toStrictEqual({ rows: 0, terms: 0, unsupported: 1, failed: 1 })
expect(report.rowErrors).toStrictEqual([
{ rawLocation: 'wxr://item/1', message: 'wp:nav_menu_item:post_type 未配置映射' },
{ rawLocation: 'wxr://item/2', message: 'post_id 缺失或非法' },
])
})
})
describe('unsupported / error 事件加法式补 postId(Plan 3 Task 1 前置事实 12)', () => {
it('unsupported(nav_menu_item):post_id 存在 → 事件携带 postId(String 化,含负值)', async () => {
const xml = wxrDoc(
itemXml({ postId: 401, postType: 'nav_menu_item' }) + itemXml({ postId: -7, postType: 'custom_block' }),
)
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
expect(events[0]).toMatchObject({
kind: 'unsupported',
sourceType: 'wp:nav_menu_item',
postId: '401',
})
// 负值 postId 与 row.sourceId 同口径(String 原样,'-7' 不归一为 '7')
expect(events[1]).toMatchObject({ kind: 'unsupported', sourceType: 'wp:custom_block', postId: '-7' })
})
it('error(post_id 缺失):postId 键缺席——不硬造 source_id(报告照常按计数归桶)', async () => {
const xml = wxrDoc(itemXml({ postId: null, postType: 'post' }))
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = events[0]
if (ev === undefined || ev.kind !== 'error') throw new Error('期望 error 事件')
expect('postId' in ev).toBe(false)
expect((ev as { postId?: string }).postId).toBeUndefined()
// 报告聚合不受加法字段影响:failed 计数照常
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.totals).toStrictEqual({ rows: 0, terms: 0, unsupported: 0, failed: 1 })
})
it('row 事件不受影响:RowResult 五键结构不变(postId 只加在 unsupported/error 变体上)', async () => {
const events = await collect(
runWxrAdapter(fromXml(wxrDoc(itemXml({ postId: 402 }))), { mapConfig: { locale: 'zh-CN' } }),
)
const ev = soleRow(events)
expect(Object.keys(ev).sort()).toStrictEqual(['changed', 'issues', 'kind', 'row', 'sanitizeDiffs'])
})
})
describe('wppop 解析失败路径全链贯通(Task 4 已实现,Task 6 验证贯通)', () => {
const RAW = 'not-php-at-all'
it('issues 记账 + extras.phpRaw 保留 + 报告行级错误收录', async () => {
const xml = wxrDoc(itemXml({ postId: 501, metas: [{ key: 'wppop_sections', value: RAW }] }))
const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }))
const ev = soleRow(events) // 失败不崩不丢:row 照常产出
expect(ev.issues.some((i) => i.includes('wppop_sections') && i.includes('phpRaw'))).toBe(true)
expect(ev.row.extras.phpRaw).toBe(RAW)
expect(Object.hasOwn(ev.row.extras, 'wppop_sections')).toBe(false) // 两键互斥不残留混态
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.totals.rows).toBe(1)
expect(report.rowErrors.some((e) => e.message.includes('wppop_sections'))).toBe(true)
})
})
describe('JSON 可序列化(phpValueToJson 防线全链再断言)', () => {
it('mini.xml 报告对象 JSON.stringify → parse 往返等值(无 Map/循环引用)', async () => {
const report = await runWxrAdapterReport(createReadStream(FIXTURE), {
mapConfig: { locale: 'zh-CN' },
})
const json = JSON.stringify(report)
expect(JSON.parse(json)).toStrictEqual(report)
})
it('row 事件(含 wppop 解析产物 extras)逐条可序列化', async () => {
const events = await collect(
runWxrAdapter(createReadStream(FIXTURE), { mapConfig: { locale: 'zh-CN' } }),
)
for (const ev of rowEvents(events)) {
expect(() => JSON.stringify(ev)).not.toThrow()
expect(JSON.parse(JSON.stringify(ev) as string).row.sourceId).toBe(ev.row.sourceId)
}
})
})
describe('致命解析错误:流拒绝 + 报告记 fatalError(Task 7 wxr:probe 消费通道)', () => {
const TRUNCATED =
'<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"><channel>' +
itemXml({ postId: 601, content: '<p>done before truncation</p>' }) +
'<item><title>never closes'
it('runWxrAdapter:错误前已完成的 row 确定交付,随后以 WxrParseError 拒绝', async () => {
const { events, error } = await collectUntilError(
runWxrAdapter(fromXml(TRUNCATED), { mapConfig: { locale: 'zh-CN' } }),
)
expect(error).toBeInstanceOf(WxrParseError)
expect(rowEvents(events).map((r) => r.row.sourceId)).toStrictEqual(['601'])
})
it('runWxrAdapterReport:不向上抛——fatalError 记账、已完成部分照常聚合', async () => {
const report = await runWxrAdapterReport(fromXml(TRUNCATED), {
mapConfig: { locale: 'zh-CN' },
})
expect(report.fatalError).toContain('WxrParseError')
expect(report.totals.rows).toBe(1)
// 正文纯白名单 → changed=false(Skipped 桶);断言点在"已完成行照常聚合"而非桶别
expect(report.counts['wp:post']).toMatchObject({ skipped: 1 })
})
})
describe('IO 错误(Node 系统错误)归入 fatalError(审查 Fix 1)', () => {
it('流中途 ENOENT 形态错误:报告不抛、fatalError 含 code 与 path', async () => {
const report = await runWxrAdapterReport(
ioErrorStream('<?xml version="1.0"?><rss><channel>', 'ENOENT', 'D:\\wxr\\missing.xml'),
{ mapConfig: { locale: 'zh-CN' } },
)
expect(report.fatalError).toContain('ENOENT')
expect(report.fatalError).toContain('missing.xml')
// 已聚合部分照常保留(错误发生于首个事件完成前——零行零错误)
expect(report.totals.rows).toBe(0)
})
it('无 code 的程序 bug(TypeError):原样上抛,不静默记入报告', async () => {
let sent = false
const buggy = new Readable({
read() {
if (sent) return
sent = true
this.push('<?xml version="1.0"?>')
queueMicrotask(() => this.destroy(new TypeError('program bug')))
},
})
await expect(
runWxrAdapterReport(buggy, { mapConfig: { locale: 'zh-CN' } }),
).rejects.toThrow(TypeError)
})
})
describe('rowErrors 封顶(审查 Fix 2:超出部分只计数不固化)', () => {
it('对抗性输入 >10_000 差异:数组截断至上限、truncated 计数准确、清洗统计不截断', async () => {
const diffCount = 10_050
const xml = wxrDoc(
itemXml({ postId: 901, content: '<script></script>'.repeat(diffCount) }),
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.sanitize.byDiffType['removed-tag']).toBe(diffCount) // 统计维度完整
expect(report.sanitize.changedRows).toBe(1)
expect(report.rowErrors).toHaveLength(10_000)
expect(report.rowErrorsTruncated).toBe(diffCount - 10_000)
// 截断不破坏对象可序列化性
expect(JSON.parse(JSON.stringify(report) as string).rowErrorsTruncated).toBe(50)
})
})
describe('timings 注入契约(审查 Fix 3:分段计时侧信道)', () => {
it('多 chunk 流消费完成后:parseMs>0、sanitizeMs>=0(外部注入对象被原地累计)', async () => {
const timings: AdapterTimings = { parseMs: 0, sanitizeMs: 0 }
const xml = wxrDoc(itemXml({ postId: 801, content: '<p>body</p><script>x</script>' }))
const events = await collect(
runWxrAdapter(fromXmlChunked(xml), { mapConfig: { locale: 'zh-CN' }, timings }),
)
expect(rowEvents(events)).toHaveLength(1)
expect(timings.parseMs).toBeGreaterThan(0) // 解析真实发生且被计时(await 窗口累计)
expect(timings.sanitizeMs).toBeGreaterThanOrEqual(0) // bodyHtml 非空 → 清洗计时分支已走
})
})
describe('纯 term 流(审查 Fix 4:无 item 输入的报告形态)', () => {
it('仅 wp:term:counts 空对象、rows=0、terms=2、无行级错误', async () => {
const xml = wxrDoc(
'<wp:term><wp:term_id>31</wp:term_id><wp:term_taxonomy>category</wp:term_taxonomy>' +
'<wp:term_slug>company</wp:term_slug><wp:term_name>Company</wp:term_name></wp:term>' +
'<wp:term><wp:term_id>32</wp:term_id><wp:term_taxonomy>product_cat</wp:term_taxonomy>' +
'<wp:term_slug>pipe-fittings</wp:term_slug><wp:term_name>Pipe Fittings</wp:term_name></wp:term>',
)
const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })
expect(report.counts).toStrictEqual({})
expect(report.totals).toStrictEqual({ rows: 0, terms: 2, unsupported: 0, failed: 0 })
expect(report.rowErrors).toStrictEqual([])
expect(report.fatalError).toBeNull()
})
})
describe('PARSER_VERSION 漂移保险(审查 Fix 5:fail loud)', () => {
it('声明主版本 === 实际安装的 saxes 主版本(升级依赖而漏改常量 → 此处爆红)', () => {
const req = createRequire(import.meta.url)
const installed = (req('saxes/package.json') as { version: string }).version
const declared = PARSER_VERSION.match(/^saxes (\d+)\./)
expect(declared).not.toBeNull()
expect(Number(declared?.[1])).toBe(Number(installed.split('.')[0]))
})
})