4c4c43f7d9
- 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>
507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
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()
|
||
})
|
||
})
|