// 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 { yield* events } /** 事件流收集(mini 全链一饮而尽) */ async function collectMiniEvents(): Promise { 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[0], paths?: { productsPath?: string; newsPath?: string }, ): ReturnType { 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 { 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 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 { 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() 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() 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 }) } }) }) })