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>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prebuild": "pnpm --filter @sf/contracts build",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ export const UNKNOWN_STATUS = 'unknown'
|
|||||||
* thumbnail → attachmentRefs{role:'featured'};gallery(逗号分隔 id)→ {role:'gallery'};
|
* thumbnail → attachmentRefs{role:'featured'};gallery(逗号分隔 id)→ {role:'gallery'};
|
||||||
* seoTitle/seoDescription → seo.title/description;wppopSections → PHP 解析(见 mapWppop);
|
* seoTitle/seoDescription → seo.title/description;wppopSections → PHP 解析(见 mapWppop);
|
||||||
* 其余 meta 一律原样进 extras。
|
* 其余 meta 一律原样进 extras。
|
||||||
|
*
|
||||||
|
* 同 item 内键冲突的三方策略(审查 Fix 3,JSDoc 冻结):
|
||||||
|
* - 未识别键 → **后者覆盖**(文档序 last-wins,进 extras);
|
||||||
|
* - 单值键(thumbnail/seoTitle/seoDescription)→ **先者胜出** + issue
|
||||||
|
* (照 WP get_post_meta($id,$key,true) 取首值的语义;首个出现即占位,即便其值
|
||||||
|
* 为空/非法也不被后续同键值顶替——重复即数据异常,显式报告不猜);
|
||||||
|
* - wppopSections → **最后一次出现决定终态**:成功则清 extras.phpRaw,失败则清
|
||||||
|
* extras.wppop_sections(issues 为追加式日志,不随终态回滚)。
|
||||||
|
* gallery 为累积语义(多段并集),不在单值策略内。
|
||||||
*/
|
*/
|
||||||
export const META_KEYS = Object.freeze({
|
export const META_KEYS = Object.freeze({
|
||||||
thumbnail: '_thumbnail_id',
|
thumbnail: '_thumbnail_id',
|
||||||
@@ -66,6 +75,8 @@ export interface MapConfig {
|
|||||||
/**
|
/**
|
||||||
* post_type → sourceType 路由;缺省 {@link DEFAULT_TYPE_ROUTES}。
|
* post_type → sourceType 路由;缺省 {@link DEFAULT_TYPE_ROUTES}。
|
||||||
* 传则**整体替换**(非合并)——需增改请展开默认表:`{ ...DEFAULT_TYPE_ROUTES, ... }`。
|
* 传则**整体替换**(非合并)——需增改请展开默认表:`{ ...DEFAULT_TYPE_ROUTES, ... }`。
|
||||||
|
* 路由值不得为空字符串(post_type 映射到空 sourceType 是调用方程序错误,
|
||||||
|
* mapItem 直接抛 TypeError fail fast——审查 Fix 5)。
|
||||||
*/
|
*/
|
||||||
typeRoutes?: Readonly<Record<string, string>>
|
typeRoutes?: Readonly<Record<string, string>>
|
||||||
}
|
}
|
||||||
@@ -86,12 +97,17 @@ export type MappedResult =
|
|||||||
* wppop_sections 分流:PHP 串经 phpUnserialize + phpValueToJson 进 extras(Map 陷阱
|
* wppop_sections 分流:PHP 串经 phpUnserialize + phpValueToJson 进 extras(Map 陷阱
|
||||||
* 防线:phpValueToJson 前的 Map 不可 JSON.stringify,会静默变 {});任何解析失败
|
* 防线:phpValueToJson 前的 Map 不可 JSON.stringify,会静默变 {});任何解析失败
|
||||||
* (PhpParseError/PhpLimitError)不崩不丢——原串存 extras.phpRaw 并记 issue。
|
* (PhpParseError/PhpLimitError)不崩不丢——原串存 extras.phpRaw 并记 issue。
|
||||||
|
*
|
||||||
|
* 同 item 重复 wppop_sections(审查 Fix 3b):最后一次出现决定终态——成功清
|
||||||
|
* extras.phpRaw,失败对称清 extras.wppop_sections(两键互斥,不残留混态)。
|
||||||
*/
|
*/
|
||||||
function mapWppop(raw: string, extras: Record<string, unknown>, issues: string[]): void {
|
function mapWppop(raw: string, extras: Record<string, unknown>, issues: string[]): void {
|
||||||
try {
|
try {
|
||||||
extras[META_KEYS.wppopSections] = phpValueToJson(phpUnserialize(raw))
|
extras[META_KEYS.wppopSections] = phpValueToJson(phpUnserialize(raw))
|
||||||
|
delete extras.phpRaw
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
extras.phpRaw = raw
|
extras.phpRaw = raw
|
||||||
|
delete extras[META_KEYS.wppopSections]
|
||||||
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
issues.push(`meta ${META_KEYS.wppopSections} PHP 解析失败(${detail}),原串保留于 extras.phpRaw`)
|
issues.push(`meta ${META_KEYS.wppopSections} PHP 解析失败(${detail}),原串保留于 extras.phpRaw`)
|
||||||
}
|
}
|
||||||
@@ -106,13 +122,28 @@ function mapWppop(raw: string, extras: Record<string, unknown>, issues: string[]
|
|||||||
* 字段以"缺席"表达"源无此值",而非空串。
|
* 字段以"缺席"表达"源无此值",而非空串。
|
||||||
*/
|
*/
|
||||||
export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
||||||
|
// config 校验先于一切(程序员错误 fail fast——审查 Fix 5):空串路由值会产出
|
||||||
|
// sourceType:'' 的 row,静默穿透守卫的非空不变量
|
||||||
|
if (config.typeRoutes !== undefined) {
|
||||||
|
for (const [key, value] of Object.entries(config.typeRoutes)) {
|
||||||
|
if (value === '') {
|
||||||
|
throw new TypeError(
|
||||||
|
`MapConfig.typeRoutes 含空字符串路由值(key=${JSON.stringify(key)}):post_type 必须映射到非空 sourceType`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Task 3 审查移交策略:post_id 是 sourceId 唯一来源,缺失/非法即无法建立稳定身份
|
// Task 3 审查移交策略:post_id 是 sourceId 唯一来源,缺失/非法即无法建立稳定身份
|
||||||
if (item.postId === null) {
|
if (item.postId === null) {
|
||||||
return { kind: 'error', issue: 'post_id 缺失或非法', rawLocation: item.rawLocation }
|
return { kind: 'error', issue: 'post_id 缺失或非法', rawLocation: item.rawLocation }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原型键防御(审查 Fix 1):postType/status 来自不可信输入,裸索引会命中继承属性
|
||||||
|
// (如 'constructor'/'valueOf' 返回函数,绕过 undefined 检查被当合法值使用)——
|
||||||
|
// 一律 Object.hasOwn 守卫,继承键视同未配置/未知
|
||||||
const routes = config.typeRoutes ?? DEFAULT_TYPE_ROUTES
|
const routes = config.typeRoutes ?? DEFAULT_TYPE_ROUTES
|
||||||
const sourceType = routes[item.postType]
|
const sourceType = Object.hasOwn(routes, item.postType) ? routes[item.postType] : undefined
|
||||||
if (sourceType === undefined) {
|
if (sourceType === undefined) {
|
||||||
return {
|
return {
|
||||||
kind: 'unsupported',
|
kind: 'unsupported',
|
||||||
@@ -123,7 +154,7 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const issues: string[] = []
|
const issues: string[] = []
|
||||||
const knownStatus = STATUS_MAP[item.status]
|
const knownStatus = Object.hasOwn(STATUS_MAP, item.status) ? STATUS_MAP[item.status] : undefined
|
||||||
const status = knownStatus ?? UNKNOWN_STATUS
|
const status = knownStatus ?? UNKNOWN_STATUS
|
||||||
if (knownStatus === undefined) {
|
if (knownStatus === undefined) {
|
||||||
issues.push(`status 值未识别:${JSON.stringify(item.status)},已映射为 '${UNKNOWN_STATUS}'`)
|
issues.push(`status 值未识别:${JSON.stringify(item.status)},已映射为 '${UNKNOWN_STATUS}'`)
|
||||||
@@ -142,9 +173,21 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
|||||||
|
|
||||||
const attachmentRefs: UniversalRow['attachmentRefs'] = []
|
const attachmentRefs: UniversalRow['attachmentRefs'] = []
|
||||||
const seo: Record<string, unknown> = {}
|
const seo: Record<string, unknown> = {}
|
||||||
const extras: Record<string, unknown> = {}
|
// 原型污染防御(审查 Fix 4):null 原型字典——meta key 是不可信输入,普通对象上
|
||||||
|
// `extras['__proto__'] = v` 命中 setter 被静默吞掉(字符串原型非对象,赋值无效果);
|
||||||
|
// null 原型下该键成为普通自有键。isUniversalRow/JSON.stringify/展开复制均兼容。
|
||||||
|
const extras: Record<string, unknown> = Object.create(null)
|
||||||
|
// 单值键占位标记(冲突策略见 META_KEYS JSDoc):首个出现即占位,重复 → issue + 取先者
|
||||||
|
let thumbnailSeen = false
|
||||||
|
let seoTitleSeen = false
|
||||||
|
let seoDescriptionSeen = false
|
||||||
for (const meta of item.metas) {
|
for (const meta of item.metas) {
|
||||||
if (meta.key === META_KEYS.thumbnail) {
|
if (meta.key === META_KEYS.thumbnail) {
|
||||||
|
if (thumbnailSeen) {
|
||||||
|
issues.push(`meta ${META_KEYS.thumbnail} 重复,取先者`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
thumbnailSeen = true
|
||||||
const sourceId = meta.value.trim()
|
const sourceId = meta.value.trim()
|
||||||
if (sourceId === '') {
|
if (sourceId === '') {
|
||||||
// 畸形:meta 存在但值为空(对照:空 gallery 值 = 无图库,合法)
|
// 畸形:meta 存在但值为空(对照:空 gallery 值 = 无图库,合法)
|
||||||
@@ -153,14 +196,25 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult {
|
|||||||
}
|
}
|
||||||
attachmentRefs.push({ role: 'featured', sourceId })
|
attachmentRefs.push({ role: 'featured', sourceId })
|
||||||
} else if (meta.key === META_KEYS.gallery) {
|
} else if (meta.key === META_KEYS.gallery) {
|
||||||
|
// 累积语义:多段 gallery meta 的引用按文档序并入(无冲突策略)
|
||||||
for (const part of meta.value.split(',')) {
|
for (const part of meta.value.split(',')) {
|
||||||
const sourceId = part.trim()
|
const sourceId = part.trim()
|
||||||
if (sourceId !== '') attachmentRefs.push({ role: 'gallery', sourceId })
|
if (sourceId !== '') attachmentRefs.push({ role: 'gallery', sourceId })
|
||||||
}
|
}
|
||||||
} else if (meta.key === META_KEYS.seoTitle) {
|
} else if (meta.key === META_KEYS.seoTitle) {
|
||||||
seo.title = meta.value
|
if (seoTitleSeen) {
|
||||||
|
issues.push(`meta ${META_KEYS.seoTitle} 重复,取先者`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seoTitleSeen = true
|
||||||
|
if (meta.value !== '') seo.title = meta.value
|
||||||
} else if (meta.key === META_KEYS.seoDescription) {
|
} else if (meta.key === META_KEYS.seoDescription) {
|
||||||
seo.description = meta.value
|
if (seoDescriptionSeen) {
|
||||||
|
issues.push(`meta ${META_KEYS.seoDescription} 重复,取先者`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seoDescriptionSeen = true
|
||||||
|
if (meta.value !== '') seo.description = meta.value
|
||||||
} else if (meta.key === META_KEYS.wppopSections) {
|
} else if (meta.key === META_KEYS.wppopSections) {
|
||||||
mapWppop(meta.value, extras, issues)
|
mapWppop(meta.value, extras, issues)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ function expectRowInvariants(row: UniversalRow): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** extras 为 null 原型字典(原型污染防御,审查 Fix 4):展开为普通对象再作整体等值比较 */
|
||||||
|
function plain(extras: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return { ...extras }
|
||||||
|
}
|
||||||
|
|
||||||
describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
|
describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
|
||||||
it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => {
|
it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => {
|
||||||
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG))
|
const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG))
|
||||||
@@ -112,7 +117,7 @@ describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => {
|
|||||||
creator: 'editor',
|
creator: 'editor',
|
||||||
})
|
})
|
||||||
// 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余
|
// 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余
|
||||||
expect(row.extras).toStrictEqual({})
|
expect(plain(row.extras)).toStrictEqual({})
|
||||||
expectRowInvariants(row)
|
expectRowInvariants(row)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -281,7 +286,7 @@ describe('mapItem:meta 分流表', () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
expect(issues).toStrictEqual([])
|
expect(issues).toStrictEqual([])
|
||||||
expect(row.extras).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' })
|
expect(plain(row.extras)).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' })
|
||||||
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }])
|
expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -348,3 +353,154 @@ describe('mapItem:category 引用形状防线', () => {
|
|||||||
expectRowInvariants(row)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user