Files
cms-website/packages/adapter-wordpress/test/adapter.test.ts
T
浅念 6573b8b326 feat(adapter-wp): 全链组装与导入前报告
- src/versions.ts:ADAPTER/MAPPING/SANITIZER_VERSION 三常量(bump 语义 JSDoc 冻结:
  映射表/清洗白名单实质变更才 bump 对应项,三者独立演进)+ PARSER_VERSION(saxes 硬编码)
- src/adapter.ts:runWxrAdapter 串联 parseWxrStream→mapItem→sanitizeBodyHtml 为
  AsyncGenerator<AdapterEvent>;term 旁路直通;bodyHtml 为空跳过清洗零 diff;
  changed 用 html!==input(Task 5 移交:diffs.length===0 ≠ 未变更);validateUniversalRow
  加法式契约复检(不动 contracts);解析/清洗分段计时(parser.next() await 窗口归属解析)
- src/report.ts:runWxrAdapterReport 聚合(首条版本块/按 sourceType Created-Skipped(无
  变化)-Failed-Unsupported/清洗统计按 diff.type+changedRows/行级错误 rawLocation+message
  +excerpt/致命 WxrError 记 fatalError 不上抛——wxr:probe 消费通道);renderReportHtml
  所有动态文本经 escapeHtml(<>&"',Task 5 移交的标记注入面防线)
- test/adapter.test.ts:19 用例(mini 全链/changed 语义/报告结构/恶意样本转义/
  unsupported-error 汇聚/wppop 失败贯通/JSON 可序列化/致命解析错误交付确定性)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 11:44:55 +08:00

406 lines
18 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 { fileURLToPath } from 'node:url'
import { PassThrough, type 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 RowResult } from '../src/adapter.js'
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '../src/report.js'
import { ADAPTER_VERSION, MAPPING_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
}
/** 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: [],
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('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 })
})
})