0e8b2b5356
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>
567 lines
25 KiB
TypeScript
567 lines
25 KiB
TypeScript
// 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('<script src="x">')
|
||
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]))
|
||
})
|
||
})
|