Files
cms-website/packages/adapter-wordpress/test/item-to-row.test.ts
T
浅念 c99bbbb29e feat(adapter-wp): WXR→UniversalRow 映射器(status 值域/悬空保留/unsupported 分流)
- mapItem 纯函数:WxrItem → MappedResult 三分支(row/unsupported/error),
  DEFAULT_TYPE_ROUTES/STATUS_MAP/META_KEYS 三冻结表导出
- Plan 1 移交落实:status 值域表驱动(未知→unknown+issue);property 断言
  (isUniversalRow+refs 形状非空)测试钉死
- Task 3 审查移交:postId null→error(不产 row 不猜);-7 原样;-0 归一 '0'
- taxonomyRefs 锚点=category @nicename(=term slug,Task 8 实数据核对后冻结)
- 悬空 gallery 203/204 原样保留;wppop 解析失败→extras.phpRaw+issue(不崩不丢)
- Task 2 审查移交补测四例:d: 键拒绝/NUL 往返/s:1:"0"→Map/1MB<50ms 烟雾
- tsconfig.build.json:src 首次引 @sf/contracts 后 build 经 contracts dist
  声明解析(rootDir TS6059;typecheck/vitest/tsx 仍 src 直连,ADR-016 归口)
- 142→173 测试全绿;typecheck/lint 绿

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

351 lines
14 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)
}
}
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(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(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)
})
})