bb2d381308
- 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>
838 lines
40 KiB
TypeScript
838 lines
40 KiB
TypeScript
// 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|