9d2cc0a46d
§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>
677 lines
32 KiB
TypeScript
677 lines
32 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 }
|
||
}
|
||
|
||
/** 手构 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|