Files
cms-website/tests/integration/import-routes.test.ts
T
浅念 9d2cc0a46d feat(import): 路由注册表生成
§6.1 处置全表落 route_registry(route 域唯一写入方):
- 内容 permalink → html(路径保真 + target_content_id 经 source_records 关联);
  首页 / 与主列表 /products/ /news/(CLI 参数注入)finish 期生成;
- category/product_cat → html(public_base_path 常量 + term slug);post_tag/
  product_tag → 301 对应主列表(terms×taxonomies 全表 finish 期生成);
- attachment 页一律 301 媒体文件(前置事实 10 降级一条汇总 issue;暂存事件、
  finish 反查 media_assets——依赖 media-writer 先落资产的时序契约);
  ?attachment_id= 查询形态走 query_key 单独建模(spec §5.6 查询路由不并 path);
- 上传媒体路径 → asset(media_assets 全表;target_file 为产物路径占位);
- ?p=/&page_id= 不插行(route_kind 六值无 excluded——查询路由 SP3),计数
  routesExcludedQueries + 逐条 issue 落 excluded_with_reason 报告语义;
- ADR-007 双键:/About 与 /about 并存 + collision_key 折叠预警不计失败;
  同 exact_key 异 owner 冲突保留先到 + issue 列后者;
- upsert 三分:miss INSERT / 同 owner 描述字段 diff UPDATE / 异 owner 冲突;
  幂等重跑零计数,--news-path 变化触发 tag 301 目标同行 UPDATE。

counts 独占五键(route 前缀):routesCreated/routesUpdated/routesRedirects/
routesCollisions/routesExcludedQueries。315 测试(+5);typecheck/lint 绿。

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

677 lines
32 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 }
}
/** 手构 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] }
}
}
/** 本文件写入的行清场: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= 查询形态:不插 route_registry 行(查询路由 SP3),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' }),
]
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,
sourceRecords: 2,
skipped: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 2,
})
const excluded = report.issues.filter((i) => i.includes('查询 URL 排除'))
expect(excluded).toHaveLength(2)
expect(excluded.some((i) => i.includes('p=999'))).toBe(true)
expect(excluded.some((i) => i.includes('page_id=888'))).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 })
}
})
})
})