Files
cms-website/tests/integration/import-routes.test.ts
T
浅念 bb2d381308 fix(import): finish 域路径安全守卫;attachment ?p= 排除对齐;歧义/降级计数精化;permalink 形态覆盖
- isSafePath 扩至 finish 域②(分类页 public_base_path+slug)与域④(media_assets public_path)——WXR 原始 slug 与库表原样切片同携穿越向量,拒绝即 issue+不登记
- writeAttachmentRoute 与内容行同口径排除 ?p=/&page_id=(?attachment_id= 不在排除表,照常 query_key 建模),排除计数 routesExcludedQueries
- 多资产歧义消息改"命中 ≥2 行"(LIMIT 2 探针只知下界)
- 降级汇总去虚报:writeRoute 返回四态,downgraded 只计实际写入(created/updated)——重复 attachment 事件 no-op 不重复计数
- foldCollisionKey JSDoc 钉死:折叠仅 JS 侧计算落列,勿用 PG normalize 库侧重算(ICU 版本差异面)
- 测试补 permalink 形态 attachment 301(纯路径行 query_key NULL)+ 重复事件降级计数"1 条"断言 + 双 finish 域安全守卫拒绝用例(317 全绿)

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

838 lines
40 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.
// Plan 3 Task 5 失败测试(先于实现):路由注册表写入器(spec §6.1 处置全表 + ADR-007 双键 + 幂等)。
// 覆盖契约(对照计划 Task 5 与前置事实 9/10/11、spec §5.6/§6.1/§6.2):
// 1. mini 全链 §6.1 全表:内容 permalink → html(.html 后缀路径保真 + target_content_id 关联);
// 首页 / → html;主列表 /products/ /news/(CLI 参数注入,默认即此)→ html;分类页
// (public_base_path 常量 + term slug,如 /category/company/)→ html;媒体上传路径 → asset
// (media_assets.public_path,target_file 占位);attachment 页 → 一律 301 媒体文件
// (?attachment_id= 查询形态,query_key 单独建模;前置事实 10 降级一条汇总 issue);
// 2. ?p=/&page_id= 查询形态:不插 route_registry 行(route_kind 六值无 excluded——查询路由
// SP3),routesExcludedQueries 计数 + issue 说明(spec §6.1 excluded_with_reason 报告语义);
// 3. exact/collision 双键(ADR-007):/About 与 /about 两行并存 + collision 预警 issue 不计失败;
// 同 exact_key 双内容冲突 → 保留文档序先到 + issue 列后者(不静默覆盖);
// 4. attachment 301 目标缺席(media_assets 无行):路由不登记 + issue 如实报告(§7.6);
// 5. 幂等:重跑同目标 0 新行 0 更新;目标变(--news-path 参数变化 → tag 301 目标变)→ UPDATE
// 不新增(route 表可变)。
// counts 键(route 前缀独占):routesCreated/routesUpdated/routesRedirects/routesCollisions/
// routesExcludedQueries——与 content/term/media 前缀并存无碰撞(全局命名空间契约)。
// 编排顺序契约:content 先于 route(entry 锚点经 source_records 解析);media.finish 先于
// route.finish(attachment 301 反查 media_assets 需资产已齐)。运行于 globalSetup 临时库
// (ADR-015);报告根 os.tmpdir,finally 清理。
import { createReadStream } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { runWxrAdapter, type AdapterEvent } from '@sf/adapter-wp'
import type { UniversalRow } from '@sf/contracts'
import type { ImportPipeline } from '../../apps/api/src/modules/import/import.service.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.service.js'
import { createContentEventHandler } from '../../apps/api/src/modules/import/content-writer.js'
import { createTermEventHandler } from '../../apps/api/src/modules/import/term-writer.js'
import { createMediaEventHandlerFromContext } from '../../apps/api/src/modules/import/media-writer.js'
import {
DEFAULT_NEWS_PATH,
DEFAULT_PRODUCTS_PATH,
createRouteEventHandler,
} from '../../apps/api/src/modules/import/route-writer.js'
import { registerSource } from '../../apps/api/src/modules/source/register.service.js'
import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js'
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/** 伪 PNG 体(魔数校验只看签名——载荷唯一避免跨测试撞哈希) */
function pngBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')])
}
/** 手构事件流的 AsyncIterable 包装(每次调用产新迭代器——重跑各流独立) */
async function* eventsOf(events: AdapterEvent[]): AsyncGenerator<AdapterEvent, void, void> {
yield* events
}
/** 事件流收集(mini 全链一饮而尽) */
async function collectMiniEvents(): Promise<AdapterEvent[]> {
const out: AdapterEvent[] = []
for await (const ev of runWxrAdapter(createReadStream(WXR_FIXTURE), { mapConfig: { locale: 'en' } })) {
out.push(ev)
}
return out
}
/** 手构内容 row 事件(sourceUrl 是路由域的输入锚点;url 空串 = 缺席键) */
function contentEvent(spec: { sourceId: string; url: string; sourceType?: string }): AdapterEvent {
const row: UniversalRow = {
sourceType: spec.sourceType ?? 'wp:post',
sourceId: spec.sourceId,
status: 'publish',
locale: 'en',
taxonomyRefs: [],
attachmentRefs: [],
seo: {},
knownFields: {},
extras: {},
rawLocation: `wxr://item/${spec.sourceId}`,
}
if (spec.url !== '') row.sourceUrl = spec.url
return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false }
}
/** 手构 attachment row 事件(sourceUrl = <link> 原串:查询/permalink 形态的判别输入) */
function attachmentEvent(spec: { sourceId: string; url: string }): AdapterEvent {
const row: UniversalRow = {
sourceType: 'wp:attachment',
sourceId: spec.sourceId,
status: 'inherit',
locale: 'en',
taxonomyRefs: [],
attachmentRefs: [],
seo: {},
knownFields: {},
extras: {},
rawLocation: `wxr://item/${spec.sourceId}`,
}
if (spec.url !== '') row.sourceUrl = spec.url
return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false }
}
/** 手构 wp:term 事件(分类页/301 在 finish 期由 terms 表生成——term-writer 先物化) */
function termEvent(spec: { termId: number; taxonomy: string; slug: string; name: string }): AdapterEvent {
return {
kind: 'term',
term: {
rawLocation: `wxr://term/${spec.termId}`,
termId: spec.termId,
taxonomy: spec.taxonomy,
slug: spec.slug,
name: spec.name,
},
}
}
/** route-writer 构造(默认主列表路径——CLI 参数 --products-path/--news-path 缺省值的注入位) */
function routeHandlerOf(
ctx: Parameters<ImportPipeline>[0],
paths?: { productsPath?: string; newsPath?: string },
): ReturnType<typeof createRouteEventHandler> {
return createRouteEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
defaultLocale: ctx.defaultLocale,
productsPath: paths?.productsPath ?? DEFAULT_PRODUCTS_PATH,
newsPath: paths?.newsPath ?? DEFAULT_NEWS_PATH,
})
}
/** mini 全链编排(Task 6 雏形):content → term → media → route 逐事件分派(顺序契约:
* content 先行是 route/media 的 entry 锚点前提;media.finish 先于 route.finish 是 attachment
* 301 反查 media_assets 的时序前提);四写入器 counts 展开合并(前缀无碰撞键) */
function fullPipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const media = await createMediaEventHandlerFromContext(ctx)
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await term.handleEvent(ev)
await media.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const t = term.finish()
const m = await media.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...t.counts, ...m.counts, ...r.counts }, issues: [...c.issues, ...t.issues, ...m.issues, ...r.issues] }
}
}
/** content + term + route 组合(无媒体——attachment 301 目标缺席场景) */
function contentTermRoutePipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await term.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const t = term.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...t.counts, ...r.counts }, issues: [...c.issues, ...t.issues, ...r.issues] }
}
}
/** content + route 组合(双键/冲突/排除查询的手构场景——terms 表无行则分类页不生成) */
function contentRoutePipeline(): ImportPipeline {
return async (ctx) => {
const content = createContentEventHandler({
db: ctx.db,
siteId: ctx.siteId,
connectionId: ctx.connectionId,
snapshotId: ctx.wxrSnapshot.id,
jobId: ctx.jobId,
defaultLocale: ctx.defaultLocale,
versions: ctx.versions,
})
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await content.handleEvent(ev)
await route.handleEvent(ev)
}
const c = content.finish()
const r = await route.finish()
return { counts: { ...c.counts, ...r.counts }, issues: [...c.issues, ...r.issues] }
}
}
/** term + route 组合(tag 301 幂等/参数变化场景——无内容行) */
function termRoutePipeline(paths?: { newsPath?: string }): ImportPipeline {
return async (ctx) => {
const term = createTermEventHandler({ db: ctx.db, siteId: ctx.siteId, connectionId: ctx.connectionId })
const route = routeHandlerOf(ctx, paths)
for await (const ev of ctx.events) {
await term.handleEvent(ev)
await route.handleEvent(ev)
}
const t = term.finish()
const r = await route.finish()
return { counts: { ...t.counts, ...r.counts }, issues: [...t.issues, ...r.issues] }
}
}
/** route 单写入器(手构 attachment/直插 media_assets 场景——站点页+asset 行仍在 finish 生成) */
function routeOnlyPipeline(): ImportPipeline {
return async (ctx) => {
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await route.handleEvent(ev)
}
return route.finish()
}
}
/** 直插 media_assets 行(route finish 域④ 的输入——不经 media-writer 三级对拍) */
async function insertAssetRow(db: Db, siteId: string, connectionId: string, spec: { sourceId: string; sourceUrl: string; publicPath: string }): Promise<void> {
await db.execute(`insert into media_assets (site_id, source_connection_id, source_id, source_url,
source_path, storage_path, public_path, sha256, detected_mime, byte_size, status)
values ('${siteId}', '${connectionId}', '${spec.sourceId}', '${spec.sourceUrl}',
'fixture/source-path', 'fixture/storage-path', '${spec.publicPath}', 'fake-sha256', 'image/png', 1, 'ready')`)
}
/** 本文件写入的行清场:FK 反序(route_registry 先于 source_snapshots/sites;两张不可变表走触发器开关) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
/** mini 登记(无媒体):register → {key, reg, wxr} */
async function registerMini(db: Db, work: string) {
const key = `sfroutes-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
allowedRoots: [FIXTURES_ROOT, work],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
return { key, reg, wxr: reg.snapshots.find((s) => s.kind === 'wxr')! }
}
/** 导入报告读取 */
async function readReport(work: string, jobId: string): Promise<{
status: string
counts: Record<string, number>
issues: string[]
}> {
return JSON.parse(await readFile(join(work, 'artifacts', 'import', jobId, 'reports', 'import.json'), 'utf8'))
}
/** route_registry 全行瘦身投影(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败) */
interface RouteSlim {
path: string
q: string | null
kind: string
tStatus: string
rStatus: number | null
loc: string | null
file: string | null
idx: boolean
pri: number
ver: string
entry: string | null
}
async function routesOf(db: Db, siteId: string): Promise<RouteSlim[]> {
const rows = (await db.execute(`select route_path, query_key, route_kind, target_status, redirect_status,
redirect_location, target_file, indexable, priority, verification_status, target_content_id
from route_registry where site_id = '${siteId}' order by route_path, query_key nulls first`)) as any
return rows.map((r: any) => ({
path: r.route_path,
q: r.query_key,
kind: r.route_kind,
tStatus: r.target_status,
rStatus: r.redirect_status,
loc: r.redirect_location,
file: r.target_file,
idx: r.indexable,
pri: r.priority,
ver: r.verification_status,
entry: r.target_content_id,
}))
}
describe('路由注册表写入器(§6.1 处置全表 + ADR-007 双键)', () => {
it('mini 全链:内容/首页/列表/分类 html + 媒体 asset + attachment 301(查询形态)+ counts 全键 + 降级汇总 issue', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-full-'))
try {
const events = await collectMiniEvents()
// 媒体目录(同 import-media 用例):fixture-a 按 uploads 层级、fixture-b 扁平 + 侧车
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026/10'), { recursive: true })
const pngA = pngBytes(`routes-a-${rand()}`)
await writeFile(join(mediaDir, '2026/10/fixture-a.png'), pngA)
const guidB = `b-${rand()}.png`
await writeFile(join(mediaDir, guidB), pngBytes(`routes-b-${rand()}`))
const sidecarPath = join(work, 'media-map.json')
await writeFile(
sidecarPath,
JSON.stringify([
{ sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png', filePath: guidB },
]),
)
const key = `sfroutes-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir,
mediaMapPath: sidecarPath,
allowedRoots: [FIXTURES_ROOT, work],
storageRoot: join(work, 'storage'),
reportRoot: join(work, 'artifacts', 'register'),
})
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: fullPipeline(),
})
// 悬空引用(media 203/204)+ attachment 降级汇总 → issues 非空 → succeeded_with_issues
expect(result.status).toBe('succeeded_with_issues')
// entry 锚点对照表(source_records → content_entry_id,路由 target_content_id 关联断言用)
const entryOf = new Map<string, string>()
for (const r of (await db.execute(`select source_id, content_entry_id from source_records
where site_id = '${reg.siteId}' and source_type in ('wp:post','wp:product','wp:page')`)) as any) {
entryOf.set(r.source_id, r.content_entry_id)
}
expect(entryOf.size).toBe(3)
// 12 行全量对拍(§6.1 阶段 3 可产生的全部行类各就位):
// 1 首页 + 2 主列表 + 3 内容 permalink + 2 分类 + 2 attachment 301 + 2 媒体 asset
const rows = await routesOf(db, reg.siteId)
expect(rows).toStrictEqual([
{ path: '/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 1000, ver: 'verified_html', entry: null },
{ path: '/', q: 'attachment_id=201', kind: 'redirect', tStatus: '301', rStatus: 301, loc: '/wp-content/uploads/2026/10/fixture-a.png', file: '/wp-content/uploads/2026/10/fixture-a.png', idx: false, pri: 0, ver: 'verified_redirect', entry: null },
{ path: '/', q: 'attachment_id=202', kind: 'redirect', tStatus: '301', rStatus: 301, loc: '/wp-content/uploads/2026/10/fixture-b.png', file: '/wp-content/uploads/2026/10/fixture-b.png', idx: false, pri: 0, ver: 'verified_redirect', entry: null },
{ path: '/about-us/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('121')! },
{ path: '/category/company/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 800, ver: 'verified_html', entry: null },
{ path: '/news/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 900, ver: 'verified_html', entry: null },
// .html 后缀路径保真(spec §6.1 内容 permalink 尽量按原路径输出)
{ path: '/news/fixture-news-a.html', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('101')! },
{ path: '/product-category/pipe-fittings/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 800, ver: 'verified_html', entry: null },
{ path: '/products/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 900, ver: 'verified_html', entry: null },
{ path: '/products/fixture-product/', q: null, kind: 'html', tStatus: '200', rStatus: null, loc: null, file: null, idx: true, pri: 500, ver: 'verified_html', entry: entryOf.get('111')! },
{ path: '/wp-content/uploads/2026/10/fixture-a.png', q: null, kind: 'asset', tStatus: '200', rStatus: null, loc: null, file: '/wp-content/uploads/2026/10/fixture-a.png', idx: false, pri: 100, ver: 'verified_asset', entry: null },
{ path: '/wp-content/uploads/2026/10/fixture-b.png', q: null, kind: 'asset', tStatus: '200', rStatus: null, loc: null, file: '/wp-content/uploads/2026/10/fixture-b.png', idx: false, pri: 100, ver: 'verified_asset', entry: null },
])
// 键语义横切断言(ADR-007 双键 + host 单值 + 来源原文 + 快照溯源):
const rawRows = (await db.execute(`select host, source_path_raw, route_path, exact_route_key,
collision_key, query_policy, query_key, source_status, source_snapshot_id
from route_registry where site_id = '${reg.siteId}'`)) as any
const siteRow = (await db.execute(`select production_origin from sites where id = '${reg.siteId}'`)) as any
const expectedHost = new URL(siteRow[0].production_origin).hostname
for (const r of rawRows) {
expect(r.host).toBe(expectedHost) // 单 host = production_origin(旧 host 变体 SP3)
expect(r.exact_route_key).toBe(r.route_path) // 请求匹配键 = 路径原文(不折叠)
expect(r.source_status).toBe('from_import') // 本阶段语义(待实数据改报告语义)
expect(r.source_snapshot_id).toBe(wxr.id)
expect(r.query_policy).toBe(r.query_key === null ? 'ignore' : 'exact')
}
// collision_key 折叠(前置事实 9:NFC + lowercase + 去尾斜杠):尾斜杠折叠、根路径折空
const collisionOf = new Map(rawRows.map((r: any) => [r.route_path, r.collision_key]))
expect(collisionOf.get('/products/')).toBe('/products')
expect(collisionOf.get('/news/')).toBe('/news')
expect(collisionOf.get('/')).toBe('')
expect(collisionOf.get('/news/fixture-news-a.html')).toBe('/news/fixture-news-a.html')
// source_path_raw 永远保存来源原文(内容行 = permalink 原串;attachment = link 原串;
// asset = media_assets.source_url;站点页 = production_origin 拼路径)
const rawOf = new Map(rawRows.map((r: any) => [`${r.route_path}\u0000${r.query_key}`, r.source_path_raw]))
expect(rawOf.get('/news/fixture-news-a.html\u0000null')).toBe('https://fixture.example/news/fixture-news-a.html')
expect(rawOf.get('/\u0000attachment_id=201')).toBe('https://fixture.example/?attachment_id=201')
expect(rawOf.get('/wp-content/uploads/2026/10/fixture-a.png\u0000null')).toBe('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png')
expect(rawOf.get('/\u0000null')).toBe(`${siteRow[0].production_origin}/`)
// counts 全键:content 四键 + term 三键 + media 六键 + route 五键(全局命名空间并存无碰撞)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 3,
revisionsCreated: 3,
sourceRecords: 5,
skipped: 0,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
mediaRegistered: 2,
mediaFromSidecar: 1,
mediaMissing: 0,
mediaQuarantined: 0,
mediaDanglingRefs: 2,
mediaAmbiguous: 0,
routesCreated: 12,
routesUpdated: 0,
routesRedirects: 2,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// attachment 降级一条汇总(前置事实 10)而非每条一 issue;悬空 2 条来自 media-writer
const downgrade = report.issues.filter((i) => i.includes('attachment 页降级'))
expect(downgrade).toHaveLength(1)
expect(downgrade[0]).toContain('2 条')
expect(report.issues.filter((i) => i.includes('悬空'))).toHaveLength(2)
expect(report.issues).toHaveLength(3)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('exact/collision 双键:/About 与 /about 两行并存 + collision 预警不计失败;同 exact_key 双内容冲突保留先到 + issue 列后者', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-dual-'))
try {
const events: AdapterEvent[] = [
contentEvent({ sourceId: '301', url: 'https://h.example/About' }),
contentEvent({ sourceId: '302', url: 'https://h.example/about' }),
contentEvent({ sourceId: '303', url: 'https://h.example/dup/' }),
contentEvent({ sourceId: '304', url: 'https://h.example/dup/' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentRoutePipeline(),
})
// collision 预警 + exact 冲突都是 issue(不计失败——job 仍是 succeeded 族)
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 3 站点页 + /About + /about + /dup/(首条)= 6 行;大小写两条并存(ADR-007 语义)
expect(rows).toHaveLength(6)
const about = rows.filter((r) => r.path === '/About' || r.path === '/about')
expect(about).toHaveLength(2)
expect(about.every((r) => r.kind === 'html')).toBe(true)
// /dup/ 只有先到内容(303)的行——后者不静默覆盖
const dup = rows.filter((r) => r.path === '/dup/')
expect(dup).toHaveLength(1)
const entryOf = new Map<string, string>()
for (const r of (await db.execute(`select source_id, content_entry_id from source_records
where site_id = '${reg.siteId}' and source_id in ('303','304')`)) as any) {
entryOf.set(r.source_id, r.content_entry_id)
}
expect(dup[0]!.entry).toBe(entryOf.get('303'))
expect(dup[0]!.entry).not.toBe(entryOf.get('304'))
// collision_key 折叠同键(/About 与 /about → /about)——预警一条、两行并存
const rawRows = (await db.execute(`select route_path, collision_key from route_registry
where site_id = '${reg.siteId}' and route_path in ('/About','/about')`)) as any
expect(rawRows.find((r: any) => r.route_path === '/About').collision_key).toBe('/about')
expect(rawRows.find((r: any) => r.route_path === '/about').collision_key).toBe('/about')
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 4,
revisionsCreated: 4,
sourceRecords: 4,
skipped: 0,
routesCreated: 6,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 1,
routesExcludedQueries: 0,
})
const collision = report.issues.filter((i) => i.includes('collision_key 折叠碰撞'))
expect(collision).toHaveLength(1)
expect(collision[0]).toContain('/About')
expect(collision[0]).toContain('/about')
const conflict = report.issues.filter((i) => i.includes('exact_key 冲突'))
expect(conflict).toHaveLength(1)
expect(conflict[0]).toContain('304')
expect(conflict[0]).toContain('保留先到')
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('?p=/&page_id= 查询形态(内容行与 attachment link 同口径):不插 route_registry 行、routesExcludedQueries 计数 + issue', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-qexc-'))
try {
const events: AdapterEvent[] = [
contentEvent({ sourceId: '401', url: 'https://h.example/?p=999' }),
contentEvent({ sourceId: '402', url: 'https://h.example/?page_id=888' }),
// attachment link 的 ?p= 形态同排除(?attachment_id= 不在排除表——见下用例)
attachmentEvent({ sourceId: '501', url: 'https://h.example/?p=123' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 仅 3 条站点页(查询形态不登记——含 query_key 的行为 0)
expect(rows).toHaveLength(3)
expect(rows.every((r) => r.q === null)).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 2,
revisionsCreated: 2,
// attachment 行经 content-writer 落 record-only 足迹(前置事实 11)
sourceRecords: 3,
skipped: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 3,
})
const excluded = report.issues.filter((i) => i.includes('查询 URL 排除'))
expect(excluded).toHaveLength(3)
expect(excluded.some((i) => i.includes('p=999'))).toBe(true)
expect(excluded.some((i) => i.includes('page_id=888'))).toBe(true)
expect(excluded.some((i) => i.includes('p=123'))).toBe(true)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('attachment 301 目标缺席(media_assets 无行):路由不登记 + issue 如实报告(§7.6 禁静默)', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-amiss-'))
try {
const events = await collectMiniEvents()
const { key, reg, wxr } = await registerMini(db, work)
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: contentTermRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 无媒体管道:3 站点页 + 3 内容 + 2 分类 = 8;attachment 301/asset 均无行
expect(rows).toHaveLength(8)
expect(rows.every((r) => r.kind !== 'redirect' && r.kind !== 'asset')).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
contentEntries: 3,
revisionsCreated: 3,
sourceRecords: 5,
skipped: 0,
termsProcessed: 2,
autoCreatedTerms: 0,
contentTermsCreated: 1,
routesCreated: 8,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
const missing = report.issues.filter((i) => i.includes('301 目标缺席'))
expect(missing).toHaveLength(2)
expect(missing.some((i) => i.includes('201'))).toBe(true)
expect(missing.some((i) => i.includes('202'))).toBe(true)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('幂等:重跑同目标 0 新行 0 更新;目标变(--news-path 变化 → tag 301 目标变)→ UPDATE 不新增', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-idem-'))
try {
const events: AdapterEvent[] = [
termEvent({ termId: 41, taxonomy: 'post_tag', slug: 'foo', name: 'Foo' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
const base = {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
}
// 首跑:首页 + 2 主列表 + /tag/foo/ 301 → /news/(§6.1 tag → 对应主列表)
const first = await createImportJob(db, { ...base, events: eventsOf(events), pipeline: termRoutePipeline() })
expect(first.status).toBe('succeeded')
let rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(4)
const tagRow = rows.find((r) => r.path === '/tag/foo/')!
expect(tagRow.kind).toBe('redirect')
expect(tagRow.loc).toBe('/news/')
expect(tagRow.rStatus).toBe(301)
const firstReport = await readReport(work, first.jobId)
expect(firstReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 4,
routesUpdated: 0,
routesRedirects: 1,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 二跑(同流同参数,换幂等键):0 新行 0 更新(幂等三态的 no-op 面)
const again = await createImportJob(db, {
...base,
idempotencyKey: `sfroutes-rerun-${rand()}`,
events: eventsOf(events),
pipeline: termRoutePipeline(),
})
expect(again.status).toBe('succeeded')
rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(4)
const againReport = await readReport(work, again.jobId)
expect(againReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 0,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 三跑(--news-path /blog/):tag 301 目标变 → 同行 UPDATE(route 表可变);
// /blog/ 新列表页 INSERT;/news/ 旧行保留(陈旧行清理是 SP3 SEO diff 域,不自动 301)
const third = await createImportJob(db, {
...base,
idempotencyKey: `sfroutes-repath-${rand()}`,
events: eventsOf(events),
pipeline: termRoutePipeline({ newsPath: '/blog/' }),
})
expect(third.status).toBe('succeeded')
rows = await routesOf(db, reg.siteId)
expect(rows).toHaveLength(5)
expect(rows.find((r) => r.path === '/tag/foo/')!.loc).toBe('/blog/')
expect(rows.find((r) => r.path === '/tag/foo/')!.rStatus).toBe(301)
expect(rows.find((r) => r.path === '/news/')).toBeDefined()
expect(rows.find((r) => r.path === '/blog/')!.kind).toBe('html')
const thirdReport = await readReport(work, third.jobId)
expect(thirdReport.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 1,
routesUpdated: 1,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('attachment permalink 形态 301(纯路径行)+ 同事件重复:降级汇总只计实际写入(1 条不虚报 2 条)', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-perm-'))
try {
const { key, reg, wxr } = await registerMini(db, work)
const connRow = (await db.execute(`select source_connection_id from source_snapshots
where id = '${wxr.id}'`)) as any
// 直插资产行(finish 域④ 反查输入——不经 media-writer 三级对拍)
await insertAssetRow(db, reg.siteId, connRow[0].source_connection_id, {
sourceId: '502',
sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/extra.png',
publicPath: '/wp-content/uploads/2026/10/extra.png',
})
// permalink 形态 attachment 页(无查询串)+ 同事件重复(WXR 文档内可重复出现)
const att = attachmentEvent({ sourceId: '502', url: 'https://fixture.example/news/fixture-att-page/' })
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf([att, att]),
pipeline: routeOnlyPipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 3 站点页 + 1 permalink 形态 301(纯路径行 query_key NULL)+ 1 asset = 5
expect(rows).toHaveLength(5)
expect(rows.find((r) => r.path === '/news/fixture-att-page/')).toStrictEqual({
path: '/news/fixture-att-page/',
q: null,
kind: 'redirect',
tStatus: '301',
rStatus: 301,
loc: '/wp-content/uploads/2026/10/extra.png',
file: '/wp-content/uploads/2026/10/extra.png',
idx: false,
pri: 0,
ver: 'verified_redirect',
entry: null,
})
expect(rows.find((r) => r.path === '/wp-content/uploads/2026/10/extra.png')!.kind).toBe('asset')
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
routesCreated: 5,
routesUpdated: 0,
routesRedirects: 1,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 降级汇总只计实际写入:重复事件 no-op 不重复计数(1 条,不虚报 2 条)
const downgrade = report.issues.filter((i) => i.includes('attachment 页降级'))
expect(downgrade).toHaveLength(1)
expect(downgrade[0]).toContain('1 条')
expect(report.issues).toHaveLength(1)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('finish 域路径安全守卫:term slug 穿越段与 media public_path 穿越段拒绝登记 + issue 如实报告', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-guard-'))
try {
const { key, reg, wxr } = await registerMini(db, work)
const connRow = (await db.execute(`select source_connection_id from source_snapshots
where id = '${wxr.id}'`)) as any
// 直插带穿越段的 public_path(media-writer 对来源 URL 原样切片的形态)
await insertAssetRow(db, reg.siteId, connRow[0].source_connection_id, {
sourceId: '503',
sourceUrl: 'https://fixture.example/wp-content/uploads/../secret.png',
publicPath: '/wp-content/uploads/../secret.png',
})
const events: AdapterEvent[] = [
termEvent({ termId: 51, taxonomy: 'category', slug: '../../../evil', name: 'Evil' }),
]
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: termRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 仅 3 条站点页:穿越段分类页与穿越段 asset 行均被守卫拒绝
expect(rows).toHaveLength(3)
expect(rows.every((r) => r.path === '/' || r.path === '/products/' || r.path === '/news/')).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
const termGuard = report.issues.filter((i) => i.includes('分类页路径安全校验拒绝'))
expect(termGuard).toHaveLength(1)
expect(termGuard[0]).toContain('/category/../../../evil')
const assetGuard = report.issues.filter((i) => i.includes('媒体路径安全校验拒绝'))
expect(assetGuard).toHaveLength(1)
expect(assetGuard[0]).toContain('..')
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
})