Files
cms-website/packages/adapter-wordpress/test/item-to-row.test.ts
T
浅念 4c4c43f7d9 fix(adapter-wp): 原型键防御与对抗测试;meta 冲突策略统一(单值先者+issue/phpRaw 清理);extras 原型污染防御;config 校验;prebuild 依赖固化
- Fix 1:routes/STATUS_MAP 查键一律 Object.hasOwn 守卫('constructor'/
  'valueOf' 等继承键裸索引命中函数值绕过 undefined 检查)+ 对抗测试
- Fix 2:prebuild 脚本先建 contracts dist(fresh-clone build 不再 TS2307,
  亦防 stale dist 静默编译;已实测 rm -rf dist 后全链成功)
- Fix 3:单值 meta(thumbnail/seo×2)重复→先者胜出+issue(WP 单值语义,
  首个占位即便值非法不被顶替);wppop 重复→最后一次决定终态(成功清
  phpRaw/失败清 wppop_sections,两键互斥);三方策略 JSDoc 冻结
- Fix 4:extras 改 null 原型字典——'__proto__' meta 不再被 setter 静默
  吞掉(测试钉死自有键+JSON 往返);gallery 累积语义明确
- Fix 5:typeRoutes 空字符串路由值 → TypeError fail fast
- 173→184 测试全绿;typecheck/lint/build 绿

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

507 lines
20 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.
import { createReadStream } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isUniversalRow, type UniversalRow } from '@sf/contracts'
import { parseWxrStream, type WxrItem } from '../src/parse/wxr-stream.js'
import {
DEFAULT_TYPE_ROUTES,
mapItem,
META_KEYS,
STATUS_MAP,
UNKNOWN_STATUS,
type MapConfig,
type MappedResult,
} from '../src/map/item-to-row.js'
// mini.xml(Plan 1 夹具):5 item(post 101 / product 111 / page 121 / attachment 201,202)
// + 2 term;product 的图库 203/204 有意悬空(夹具中不存在对应 attachment item)。
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
const CONFIG: MapConfig = { locale: 'zh-CN' }
/** 经 parseWxrStream 喂 mini.xml 取真实 item 事件(端到端:解析产物即映射输入) */
async function miniItems(): Promise<WxrItem[]> {
const out: WxrItem[] = []
for await (const ev of parseWxrStream(createReadStream(FIXTURE))) {
if (ev.kind === 'item') out.push(ev.item)
}
return out
}
function byPostId(items: WxrItem[], postId: number): WxrItem {
const found = items.find((it) => it.postId === postId)
if (found === undefined) throw new Error(`mini.xml 中找不到 post ${postId}`)
return found
}
/** 手构等值 WxrItem(任务说明允许:wxr-stream 完整字段形状,缺省为最平凡合法值) */
function makeItem(overrides: Partial<WxrItem> = {}): WxrItem {
return {
rawLocation: 'wxr://item/handmade',
postId: 1,
postType: 'post',
status: 'publish',
postName: 'handmade',
link: 'https://example.com/handmade.html',
title: 'Handmade',
contentEncoded: '<p>handmade body</p>',
excerptEncoded: '',
postDate: '2026-01-01 00:00:00',
postModified: '2026-01-02 00:00:00',
attachmentUrl: '',
creator: 'admin',
metas: [],
categories: [],
...overrides,
}
}
/** 断言结果为 row 并取出( narrowing 辅助;非 row 即失败) */
function rowOf(result: MappedResult): { row: UniversalRow; issues: string[] } {
if (result.kind !== 'row') throw new Error(`期望 kind:'row',实际 ${result.kind}`)
return { row: result.row, issues: result.issues }
}
/** Plan 1 移交 property 断言:产出 row 的通用不变量(守卫 + 非空 + refs 形状) */
function expectRowInvariants(row: UniversalRow): void {
expect(isUniversalRow(row)).toBe(true)
expect(row.sourceType).not.toBe('')
expect(row.sourceId).not.toBe('')
expect(row.rawLocation).not.toBe('')
for (const ref of row.taxonomyRefs) {
expect(ref.taxonomy).not.toBe('')
expect(ref.sourceTermId).not.toBe('')
}
for (const ref of row.attachmentRefs) {
expect(ref.role).not.toBe('')
// 本映射器产出的 ref 一律以 sourceId 锚定(不产 sourceUrl 型 ref)
expect(typeof ref.sourceId === 'string' && ref.sourceId !== '').toBe(true)
}
}
/** extras 为 null 原型字典(原型污染防御,审查 Fix 4):展开为普通对象再作整体等值比较 */
function plain(extras: Record<string, unknown>): Record<string, unknown> {
return { ...extras }
}
describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => {
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG))
expect(issues).toStrictEqual([])
expect(row).toMatchObject({
sourceType: 'wp:post',
sourceId: '101',
status: 'publish',
locale: 'zh-CN',
sourceUrl: 'https://fixture.example/news/fixture-news-a.html',
slug: 'fixture-news-a',
// 原文透传(清洗是 Task 5 的职责)
bodyHtml:
'<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>',
rawLocation: 'wxr://item/1',
})
// 设计决定(JSDoc 钉死):sourceTermId 暂以 category @nicename 为锚
// (= 真实 WP 的 term slug;mini.xml 中 term 31 的 slug 恰为 'company',Task 8 实数据核对后冻结)
expect(row.taxonomyRefs).toStrictEqual([{ taxonomy: 'category', sourceTermId: 'company' }])
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '201' }])
expect(row.seo).toStrictEqual({
title: 'Fixture SEO Title A',
description: 'Fixture SEO description A.',
})
// 阶段 3 提升纪律:title/日期暂留 knownFields(键名冻结为 title/postDate/postModified)
expect(row.knownFields).toStrictEqual({
title: 'Fixture News A',
postDate: '2026-10-05 09:00:00',
postModified: '2026-10-06 10:00:00',
excerpt: 'Fixture excerpt A',
creator: 'editor',
})
// 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余
expect(plain(row.extras)).toStrictEqual({})
expectRowInvariants(row)
})
it('product(111):featured 202 + 悬空 gallery 203/204 原样保留(歧义不猜);extras.wppop_sections 为解析产物', async () => {
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 111), CONFIG))
expect(issues).toStrictEqual([])
expect(row.sourceType).toBe('wp:product')
expect(row.sourceId).toBe('111')
// 悬空 203/204 原样保留:是否存在对应媒体是入库阶段以全量数据才能裁决的事
expect(row.attachmentRefs).toStrictEqual([
{ role: 'featured', sourceId: '202' },
{ role: 'gallery', sourceId: '203' },
{ role: 'gallery', sourceId: '204' },
])
// Map 陷阱防线:必须是 phpValueToJson 后的普通对象(可 JSON.stringify,非 Map)
expect(row.extras.wppop_sections).not.toBeInstanceOf(Map)
expect(row.extras.wppop_sections).toStrictEqual([{ title: 'SpecsTab', content: '<p>Tab</p>' }])
expect(JSON.stringify(row.extras.wppop_sections)).toBe('[{"title":"SpecsTab","content":"<p>Tab</p>"}]')
expectRowInvariants(row)
})
it('page(121):sourceType wp:page,seo/refs 为空', async () => {
const { row } = rowOf(mapItem(byPostId(await miniItems(), 121), CONFIG))
expect(row.sourceType).toBe('wp:page')
expect(row.sourceId).toBe('121')
expect(row.taxonomyRefs).toStrictEqual([])
expect(row.attachmentRefs).toStrictEqual([])
expect(row.seo).toStrictEqual({})
expectRowInvariants(row)
})
it('attachment(201):sourceType wp:attachment、status inherit 原样、knownFields.attachmentUrl', async () => {
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 201), CONFIG))
expect(issues).toStrictEqual([])
expect(row.sourceType).toBe('wp:attachment')
expect(row.sourceId).toBe('201')
expect(row.status).toBe('inherit')
expect(row.knownFields.attachmentUrl).toBe(
'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png',
)
expectRowInvariants(row)
})
it('property 断言(Plan 1 移交):mini.xml 全部 5 item 均产合法 row(含 attachment 202)', async () => {
const items = await miniItems()
expect(items).toHaveLength(5)
for (const item of items) {
const { row } = rowOf(mapItem(item, CONFIG))
expectRowInvariants(row)
}
})
})
describe('mapItem:unsupported 分流(不产 row)', () => {
it.each(['nav_menu_item', 'wpcf7_contact_form', 'elementor_library', 'wp_navigation'])(
'post_type %s → {kind:unsupported, sourceType:wp:%s},不产 row',
(postType) => {
const result = mapItem(makeItem({ postType }), CONFIG)
expect(result).toStrictEqual({
kind: 'unsupported',
sourceType: `wp:${postType}`,
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/handmade',
})
expect('row' in result).toBe(false)
},
)
it('typeRoutes 自定义:整体替换语义(post 走自定义路由;product 无路由 → unsupported)', () => {
const custom: MapConfig = { locale: 'zh-CN', typeRoutes: { post: 'site:article' } }
const post = rowOf(mapItem(makeItem({ postType: 'post' }), custom))
expect(post.row.sourceType).toBe('site:article')
expect(mapItem(makeItem({ postType: 'product' }), custom).kind).toBe('unsupported')
})
})
describe('mapItem:status 值域映射(Plan 1 移交,表驱动)', () => {
it.each(['publish', 'draft', 'future', 'pending', 'private', 'trash', 'inherit'])(
'已知 status %s → 原样',
(status) => {
const { row, issues } = rowOf(mapItem(makeItem({ status }), CONFIG))
expect(row.status).toBe(status)
expect(issues).toStrictEqual([])
},
)
it("未知 status('weird'/空串)→ 'unknown' + issue(不猜不丢)", () => {
const weird = rowOf(mapItem(makeItem({ status: 'weird' }), CONFIG))
expect(weird.row.status).toBe('unknown')
expect(weird.issues).toStrictEqual([`status 值未识别:"weird",已映射为 'unknown'`])
const empty = rowOf(mapItem(makeItem({ status: '' }), CONFIG))
expect(empty.row.status).toBe('unknown')
expect(empty.issues).toHaveLength(1)
})
it('STATUS_MAP/DEFAULT_TYPE_ROUTES/META_KEYS 冻结且值域钉死', () => {
expect(Object.isFrozen(STATUS_MAP)).toBe(true)
expect(Object.isFrozen(DEFAULT_TYPE_ROUTES)).toBe(true)
expect(Object.isFrozen(META_KEYS)).toBe(true)
expect(STATUS_MAP).toStrictEqual({
publish: 'publish',
draft: 'draft',
future: 'future',
pending: 'pending',
private: 'private',
trash: 'trash',
inherit: 'inherit',
})
expect(UNKNOWN_STATUS).toBe('unknown')
expect(DEFAULT_TYPE_ROUTES).toStrictEqual({
post: 'wp:post',
page: 'wp:page',
product: 'wp:product',
attachment: 'wp:attachment',
})
expect(META_KEYS).toStrictEqual({
thumbnail: '_thumbnail_id',
gallery: '_product_image_gallery',
seoTitle: '_yoast_wpseo_title',
seoDescription: '_yoast_wpseo_metadesc',
wppopSections: 'wppop_sections',
})
})
})
describe('mapItem:postId 异常策略(Task 3 审查移交)', () => {
it('postId null → {kind:error}(不产 row 不猜)', () => {
expect(mapItem(makeItem({ postId: null }), CONFIG)).toStrictEqual({
kind: 'error',
issue: 'post_id 缺失或非法',
rawLocation: 'wxr://item/handmade',
})
})
it("postId -7(wxr-stream 严格解析 '-7' 的产物)→ sourceId '-7' 原样", () => {
const { row } = rowOf(mapItem(makeItem({ postId: -7 }), CONFIG))
expect(row.sourceId).toBe('-7')
})
it("postId -0 / 0 → sourceId '0'(-0 归一,String(-0)==='0')", () => {
const item = makeItem({ postId: -0 })
expect(Object.is(item.postId, -0)).toBe(true) // 确证喂入的确实是 -0(未被字面量归一)
expect(rowOf(mapItem(item, CONFIG)).row.sourceId).toBe('0')
expect(rowOf(mapItem(makeItem({ postId: 0 }), CONFIG)).row.sourceId).toBe('0')
})
it('post_id 缺失的 unsupported 类型:data 有效性先行于路由(error 而非 unsupported)', () => {
const result = mapItem(makeItem({ postId: null, postType: 'nav_menu_item' }), CONFIG)
expect(result.kind).toBe('error')
})
})
describe('mapItem:meta 分流表', () => {
it('未识别 meta → extras 原样(字符串键值;同 key 后者覆盖前者)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: 'custom_a', value: 'va' },
{ key: '_thumbnail_id', value: '301' },
{ key: 'custom_a', value: 'va-2' },
{ key: 'custom_b', value: 'vb' },
],
}),
CONFIG,
),
)
expect(issues).toStrictEqual([])
expect(plain(row.extras)).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' })
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }])
})
it('thumbnail/gallery 容忍空白(trim);空 gallery 值不产 ref;空 thumbnail 记 issue', () => {
const ok = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_thumbnail_id', value: ' 401 ' },
{ key: '_product_image_gallery', value: '402 , 403' },
],
}),
CONFIG,
),
)
expect(ok.row.attachmentRefs).toStrictEqual([
{ role: 'featured', sourceId: '401' },
{ role: 'gallery', sourceId: '402' },
{ role: 'gallery', sourceId: '403' },
])
// WooCommerce 语义:空 _product_image_gallery = 无图库(不报 issue);空 _thumbnail_id 为畸形(记 issue)
const emptyGallery = rowOf(
mapItem(makeItem({ metas: [{ key: '_product_image_gallery', value: '' }] }), CONFIG),
)
expect(emptyGallery.row.attachmentRefs).toStrictEqual([])
expect(emptyGallery.issues).toStrictEqual([])
const emptyThumb = rowOf(
mapItem(makeItem({ metas: [{ key: '_thumbnail_id', value: ' ' }] }), CONFIG),
)
expect(emptyThumb.row.attachmentRefs).toStrictEqual([])
expect(emptyThumb.issues).toStrictEqual(['meta _thumbnail_id 值为空,featured 引用已跳过'])
})
it('wppop 解析失败路径:extras.phpRaw 存原串 + issue(不崩不丢,row 照常产出)', () => {
const raw = 'a:1:{i:0;s:5:"ab' // 截断串 → PhpParseError
const { row, issues } = rowOf(
mapItem(makeItem({ metas: [{ key: 'wppop_sections', value: raw }] }), CONFIG),
)
expect(row.extras.phpRaw).toBe(raw)
expect(row.extras.wppop_sections).toBeUndefined()
expect(issues).toHaveLength(1)
expect(issues[0]).toContain('wppop_sections')
expect(issues[0]).toContain('phpRaw')
expectRowInvariants(row)
})
})
describe('mapItem:category 引用形状防线', () => {
it('category 缺 domain 或 nicename → 跳过该 ref 并记 issue(不产空锚点)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
categories: [
{ domain: 'category', nicename: '', value: 'X' },
{ domain: '', nicename: 'slug', value: 'Y' },
{ domain: 'post_tag', nicename: 'tag-a', value: 'TagA' },
],
}),
CONFIG,
),
)
expect(row.taxonomyRefs).toStrictEqual([{ taxonomy: 'post_tag', sourceTermId: 'tag-a' }])
expect(issues).toHaveLength(2)
expectRowInvariants(row)
})
})
describe('mapItem:原型键防御(审查 Fix 1——裸索引会命中继承属性)', () => {
it("postType 'constructor'(继承键,值为函数非路由)→ unsupported,不被当合法路由命中", () => {
const result = mapItem(makeItem({ postType: 'constructor' }), CONFIG)
expect(result).toStrictEqual({
kind: 'unsupported',
sourceType: 'wp:constructor',
reason: 'post_type 未配置映射',
rawLocation: 'wxr://item/handmade',
})
})
it("status 'valueOf'(继承键,值为函数非字符串)→ 'unknown' + issue", () => {
const { row, issues } = rowOf(mapItem(makeItem({ status: 'valueOf' }), CONFIG))
expect(row.status).toBe('unknown')
expect(issues).toStrictEqual([`status 值未识别:"valueOf",已映射为 'unknown'`])
})
it("typeRoutes 自定义表同样守卫:'toString' 键未被显式配置 → unsupported", () => {
const custom: MapConfig = { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } }
expect(mapItem(makeItem({ postType: 'toString' }), custom).kind).toBe('unsupported')
})
})
describe('mapItem:meta 冲突策略三方统一(审查 Fix 3)', () => {
it('单值键重复(thumbnail/seoTitle/seoDescription)→ 先者胜出 + issue(WP 单值语义)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_thumbnail_id', value: '501' },
{ key: '_thumbnail_id', value: '502' },
{ key: '_yoast_wpseo_title', value: 'T1' },
{ key: '_yoast_wpseo_title', value: 'T2' },
{ key: '_yoast_wpseo_metadesc', value: 'D1' },
{ key: '_yoast_wpseo_metadesc', value: 'D2' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '501' }])
expect(row.seo).toStrictEqual({ title: 'T1', description: 'D1' })
expect(issues).toStrictEqual([
'meta _thumbnail_id 重复,取先者',
'meta _yoast_wpseo_title 重复,取先者',
'meta _yoast_wpseo_metadesc 重复,取先者',
])
})
it('首个单值键即便值非法也占位:后续同键值不顶替(重复即异常,显式报告不猜)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_thumbnail_id', value: '' },
{ key: '_thumbnail_id', value: '503' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([])
expect(issues).toStrictEqual([
'meta _thumbnail_id 值为空,featured 引用已跳过',
'meta _thumbnail_id 重复,取先者',
])
})
it('wppop 先败后成:extras.phpRaw 被清除、终态为成功产物(issues 留痕不回滚)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: 'wppop_sections', value: 'a:1:{i:0;s:5:"ab' }, // 截断 → 失败
{ key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' }, // 成功
],
}),
CONFIG,
),
)
expect(Object.hasOwn(row.extras, 'phpRaw')).toBe(false)
expect(row.extras.wppop_sections).toStrictEqual(['foo'])
expect(issues).toHaveLength(1) // 首次失败仍在 issue 日志中
})
it('wppop 先成后败:对称清理——终态 phpRaw、无 wppop_sections 残留', () => {
const bad = 'a:1:{i:0;s:5:"ab'
const { row } = rowOf(
mapItem(
makeItem({
metas: [
{ key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' },
{ key: 'wppop_sections', value: bad },
],
}),
CONFIG,
),
)
expect(Object.hasOwn(row.extras, 'wppop_sections')).toBe(false)
expect(row.extras.phpRaw).toBe(bad)
})
it('gallery 累积语义:两段 gallery meta 的引用按文档序并入(不属单值冲突策略)', () => {
const { row, issues } = rowOf(
mapItem(
makeItem({
metas: [
{ key: '_product_image_gallery', value: '601' },
{ key: '_product_image_gallery', value: '602,603' },
],
}),
CONFIG,
),
)
expect(row.attachmentRefs).toStrictEqual([
{ role: 'gallery', sourceId: '601' },
{ role: 'gallery', sourceId: '602' },
{ role: 'gallery', sourceId: '603' },
])
expect(issues).toStrictEqual([])
})
})
describe('mapItem:原型污染防御(审查 Fix 4)', () => {
it("meta key '__proto__' 不静默丢失:extras 保留为自有可枚举键(null 原型字典)", () => {
const { row, issues } = rowOf(
mapItem(makeItem({ metas: [{ key: '__proto__', value: 'proto-v' }] }), CONFIG),
)
expect(issues).toStrictEqual([])
expect(Object.hasOwn(row.extras, '__proto__')).toBe(true)
expect(row.extras['__proto__']).toBe('proto-v')
expect(JSON.stringify(row.extras)).toContain('"__proto__":"proto-v"')
expectRowInvariants(row) // 守卫(含 seo/knownFields/extras 对象形状检查)仍通过
})
})
describe('mapItem:config 校验(审查 Fix 5)', () => {
it('typeRoutes 含空字符串路由值 → TypeError(程序员错误,fail fast)', () => {
expect(() => mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: '' } })).toThrow(TypeError)
expect(() =>
mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post', page: '' } }),
).toThrow(/typeRoutes/)
})
it('非空路由值不误伤:合法自定义表正常产出', () => {
expect(() =>
rowOf(mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } })),
).not.toThrow()
})
})