// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。 // 覆盖契约(对照计划 Task 2): // 1. build job 状态机:queued→snapshotting→validating(空,Task 6 填)→succeeded(dist 尚空 // ——Task 5 接真实构建时新建 job 不改行,build_input_snapshots 有不可变触发器)+ // build_input_snapshots 行(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256、 // site_config_revision_id 指向 config v1、theme_key='pilot-minimal'、node_version 等版本 // 信息列真实读 process.versions)+ 幂等键复用(同 published 集合 → reused 不重跑); // 2. 六文件物化(artifacts/jobs//input/):site.json(config v1)/content.json // (published 修订数组,data 全量 + media 引用面,slug 原样)/taxonomies.json(双源全量, // source_id 原样含 auto:)/routes.json(html+asset+redirect 并入,query_key/ // redirect_status/redirect_location/verification_status/indexable/priority 保留)/ // assets.json(ready 资产实体路径解析:register 快照 metadata.storageRoot+storage_path)/ // render-manifest.json(versions 四常量 + counts + sourceHashes 三哈希 + urlRewrite 统计); // 3. bodyHtml URL 重写:mini post 101 正文绝对 URL 图片命中 media ready 集合 → 根相对 // public_path;未命中 URL 保留原样 + rewritten/unresolved 计数入 render-manifest; // 4. canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白); // 5. NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行; // 6. ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json // 仍为 A(published 冻结语义,spec §15 阶段 4 验收明文)。 // 运行于 globalSetup 临时库(ADR-015);storage/artifacts 根 os.tmpdir,finally 清理。 import { createHash } from 'node:crypto' import { existsSync } 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 { ADAPTER_VERSION, MAPPING_VERSION, PARSER_VERSION, SANITIZER_VERSION, } from '@sf/adapter-wp' import { canonicalJson } from '../../apps/api/src/common/canonical.js' import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' import { publishSiteContent } from '../../apps/api/src/modules/publish/publish.service.js' import { registerSource } from '../../apps/api/src/modules/source/register.service.js' import { BuildError, createBuildJob, } from '../../apps/api/src/modules/build/build.service.js' import { buildMediaPathIndex, materializeRenderSnapshot, rewriteBodyHtml, } from '../../apps/api/src/modules/build/render-snapshot.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')]) } function sha256of(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex') } /** db.execute 行对象规整(原型差异会让 toStrictEqual 报"无视觉差异"失败——import-media 同款) */ function plain(rows: any[]): any[] { return rows.map((r) => ({ ...r })) } /** 单值计数便捷 */ async function countOf(db: Db, sql: string): Promise { const rows = (await db.execute(sql)) as any return Number(rows[0].n) } /** 本文件写入的行清场:构建域(不可变快照先行)+ 发布/导入/来源全链反序 */ async function cleanupSiteRows(db: Db, siteId: string): Promise { await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`) await db.execute(`delete from build_jobs where site_id = '${siteId}'`) await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`) await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`) 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}'`) } interface Work { work: string storageRoot: string allowedRoots: string[] pngA: Buffer pngB: Buffer } async function makeWork(prefix: string): Promise { const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) // 媒体目录:fixture-a/b 按旧 uploads 层级放(清单精确命中 → media_assets ready 两行) const mediaDir = join(work, 'media') await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) const pngA = pngBytes(`build-a-${rand()}`) const pngB = pngBytes(`build-b-${rand()}`) await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA) await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), pngB) return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA, pngB } } /** mini 全链(register wxr+media → import → publish):3 条 published + 2 ready 媒体 */ async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> { const key = `sfbuild-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, mediaDir: join(w.work, 'media'), allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) // gallery 203/204 悬空 → issues 非空是预期(§7.6 禁静默) expect(result.status).toBe('succeeded_with_issues') const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) expect(run.publishedCount).toBe(3) return { key, siteId: reg.siteId } } /** 读取六输入文件为解析对象 */ async function readInputFiles(inputDir: string): Promise> { const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest'] const out: Record = {} for (const n of names) { out[n] = JSON.parse(await readFile(join(inputDir, `${n}.json`), 'utf8')) } return out } describe('build job + Render Snapshot 物化', () => { it('状态机 queued→snapshotting→validating(空)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-sm') try { const { key, siteId } = await seedPublishedSite(db, w) try { const statuses: string[] = [] const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), onStatus: (s) => statuses.push(s), }) expect(result.reused).toBe(false) expect(result.status).toBe('succeeded') // 状态机序列(validating 空:Task 6 填真实门禁) expect(statuses).toStrictEqual(['queued', 'snapshotting', 'validating', 'succeeded']) // build_jobs 行:终态 + attempt/时间戳/error 空(epoch 投影——时间戳非空身份断言; // raw execute 的 timestamptz 回串不做 Date 形状断言) const job = (await db.execute(`select status, attempt, extract(epoch from started_at)::float8 as started_ts, extract(epoch from finished_at)::float8 as finished_ts, error_code from build_jobs where id = '${result.jobId}'`)) as any expect(job).toHaveLength(1) expect(job[0].status).toBe('succeeded') expect(Number(job[0].attempt)).toBe(1) expect(typeof job[0].started_ts).toBe('number') expect(job[0].started_ts).toBeGreaterThan(0) expect(typeof job[0].finished_ts).toBe('number') expect(job[0].finished_ts).toBeGreaterThanOrEqual(job[0].started_ts) expect(job[0].error_code).toBeNull() // build_input_snapshots 行:三哈希 + 主题 + config 引用 + 版本列真实值 const snap = (await db.execute(`select content_snapshot_hash, route_snapshot_hash, asset_manifest_hash, site_config_revision_id, theme_key, theme_version, node_version, pnpm_version, astro_version, locale, timezone, manifest_path, extract(epoch from build_timestamp)::float8 as build_ts from build_input_snapshots where build_job_id = '${result.jobId}'`)) as any expect(snap).toHaveLength(1) const s = snap[0] const manifest = JSON.parse(await readFile(join(result.inputDir, 'render-manifest.json'), 'utf8')) expect(s.content_snapshot_hash).toBe(manifest.sourceHashes.content) expect(s.route_snapshot_hash).toBe(manifest.sourceHashes.route) expect(s.asset_manifest_hash).toBe(manifest.sourceHashes.asset) // 三哈希独立可复算:各文件 canonicalJson 的 sha256(测试独立重算对拍) for (const [col, file] of [ ['content_snapshot_hash', 'content.json'], ['route_snapshot_hash', 'routes.json'], ['asset_manifest_hash', 'assets.json'], ] as const) { const payload = JSON.parse(await readFile(join(result.inputDir, file), 'utf8')) expect(s[col]).toBe(sha256of(canonicalJson(payload))) } const config = (await db.execute(`select scr.id from site_config_revisions scr where scr.site_id = '${siteId}'`)) as any expect(s.site_config_revision_id).toBe(config[0].id) expect(s.theme_key).toBe('pilot-minimal') expect(typeof s.theme_version).toBe('string') expect(s.theme_version).not.toBe('') // 真实读 process.versions(不硬编码) expect(s.node_version).toBe(process.versions.node) expect(typeof s.pnpm_version).toBe('string') expect(s.pnpm_version).not.toBe('') expect(s.locale).toBe('en') expect(s.timezone).not.toBe('') expect(s.manifest_path).toBe(join(result.inputDir, 'render-manifest.json')) expect(typeof s.build_ts).toBe('number') expect(s.build_ts).toBeGreaterThan(0) // 报告落盘 const report = JSON.parse(await readFile(result.reportPath, 'utf8')) expect(report.jobId).toBe(result.jobId) expect(report.status).toBe('succeeded') // 幂等复用:同 published 集合 + 同主题版本 → 同 key 复用既有 job(不重跑) const again = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), }) expect(again.reused).toBe(true) expect(again.jobId).toBe(result.jobId) expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(1) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-files') try { const { key, siteId } = await seedPublishedSite(db, w) try { const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), }) const inputDir = result.inputDir // 六文件全在(RENDER_SNAPSHOT_FILES 冻结清单) for (const name of ['site.json', 'content.json', 'taxonomies.json', 'routes.json', 'assets.json', 'render-manifest.json']) { expect(existsSync(join(inputDir, name))).toBe(true) } const files = await readInputFiles(inputDir) // site.json = config v1 数据(Task 1 首版生成的 {name,origin,locale,brand}) expect(files.site).toStrictEqual({ name: key, origin: `https://${key}.example`, locale: 'en', brand: {}, }) // content.json:3 条 published 修订(typeKey/slug 排序钉死);每条 data 全量 + // revisionId/checksum 与库内 published 修订对拍;slug 原样 const dbRows = plain( (await db.execute(`select ce.id as content_id, ce.type_key, el.locale, el.slug as locale_slug, cr.id as revision_id, cr.checksum, cr.data from entry_locales el join content_entries ce on ce.id = el.content_entry_id join content_revisions cr on cr.id = el.published_revision_id where el.site_id = '${siteId}'`)) as any, ) expect(dbRows).toHaveLength(3) expect(files.content.map((c: any) => [c.typeKey, c.slug])).toStrictEqual([ ['basic_page', 'about-us'], ['news_article', 'fixture-news-a'], ['product', 'fixture-product'], ]) for (const entry of files.content) { const row = dbRows.find((r: any) => r.revision_id === entry.revisionId) expect(row).toBeDefined() expect(entry.contentId).toBe(row.content_id) expect(entry.locale).toBe('en') expect(entry.checksum).toBe(row.checksum) // data 全量对拍(bodyHtml 除外——URL 重写另测) const { bodyHtml: _ignored, ...rest } = entry.data const { bodyHtml: _dbIgnored, ...dbRest } = JSON.parse(JSON.stringify(row.data)) expect(rest).toStrictEqual(dbRest) expect(typeof entry.data.bodyHtml).toBe('string') } // content.json media 引用面:published 修订的 media_usages(fieldPath → public_path) const newsEntry = files.content.find((c: any) => c.slug === 'fixture-news-a') const productEntry = files.content.find((c: any) => c.slug === 'fixture-product') const aPub = '/wp-content/uploads/2026/10/fixture-a.png' const bPub = '/wp-content/uploads/2026/10/fixture-b.png' expect(newsEntry.media).toStrictEqual({ 'attachmentRefs.featured': aPub, 'data.bodyHtml.img[0]': aPub, }) expect(productEntry.media).toStrictEqual({ 'attachmentRefs.featured': bPub }) // taxonomies.json:双源全量;source_id 原样(wp:term 的 '31'/'32' 字符串) expect(files.taxonomies.map((t: any) => [t.key, t.objectType, t.publicBasePath])).toStrictEqual([ ['category', 'post', '/category/'], ['product_cat', 'product', '/product-category/'], ]) const company = files.taxonomies[0].terms[0] expect({ sourceId: company.sourceId, slug: company.slug, name: company.name, parentTermId: company.parentTermId }) .toStrictEqual({ sourceId: '31', slug: 'company', name: 'Company', parentTermId: null }) expect(company.contentEntryIds).toStrictEqual([newsEntry.contentId]) const pipeFittings = files.taxonomies[1].terms[0] expect(pipeFittings.sourceId).toBe('32') expect(pipeFittings.contentEntryIds).toStrictEqual([]) // routes.json:html+asset+redirect 并入(含查询形态 redirect 与 redirect 字段面) expect(files.routes.map((r: any) => [r.routePath, r.routeKind, r.queryKey])).toStrictEqual([ ['/', 'html', null], ['/', 'redirect', 'attachment_id=201'], ['/', 'redirect', 'attachment_id=202'], ['/about-us/', 'html', null], ['/category/company/', 'html', null], ['/news/', 'html', null], ['/news/fixture-news-a.html', 'html', null], ['/product-category/pipe-fittings/', 'html', null], ['/products/', 'html', null], ['/products/fixture-product/', 'html', null], ['/wp-content/uploads/2026/10/fixture-a.png', 'asset', null], ['/wp-content/uploads/2026/10/fixture-b.png', 'asset', null], ]) const homeHtml = files.routes[0] expect({ queryPolicy: homeHtml.queryPolicy, indexable: homeHtml.indexable, priority: homeHtml.priority, verificationStatus: homeHtml.verificationStatus }) .toStrictEqual({ queryPolicy: 'ignore', indexable: true, priority: 1000, verificationStatus: 'verified_html' }) const redirectA = files.routes[1] expect({ queryPolicy: redirectA.queryPolicy, targetStatus: redirectA.targetStatus, redirectStatus: redirectA.redirectStatus, redirectLocation: redirectA.redirectLocation, indexable: redirectA.indexable }) .toStrictEqual({ queryPolicy: 'exact', targetStatus: '301', redirectStatus: 301, redirectLocation: aPub, indexable: false }) const newsHtml = files.routes.find((r: any) => r.routePath === '/news/fixture-news-a.html') expect(newsHtml.targetContentId).toBe(newsEntry.contentId) // assets.json:ready 资产实体路径解析(register 快照 metadata.storageRoot+storage_path) const bundle = plain((await db.execute(`select storage_path from source_snapshots where site_id = '${siteId}' and kind = 'media_bundle'`)) as any)[0] expect(files.assets).toHaveLength(2) const assetA = files.assets[0] expect(assetA.publicPath).toBe(aPub) expect(assetA.storagePath).toBe(`${bundle.storage_path}/2026/10/fixture-a.png`) expect(assetA.copyPath).toBe(resolve(w.storageRoot, assetA.storagePath)) expect(existsSync(assetA.copyPath)).toBe(true) expect(assetA.sha256).toBe(sha256of(w.pngA)) expect(assetA.detectedMimeType).toBe('image/png') expect(Number(assetA.byteSize)).toBe(w.pngA.length) expect(assetA.width).toBeNull() expect(assetA.height).toBeNull() expect(assetA.altText).toBeNull() // render-manifest:版本四常量 + counts + 三哈希 + 重写统计 expect(files['render-manifest'].versions).toStrictEqual({ adapter: ADAPTER_VERSION, mapping: MAPPING_VERSION, sanitizer: SANITIZER_VERSION, parser: PARSER_VERSION, }) expect(files['render-manifest'].counts).toStrictEqual({ contents: 3, taxonomies: 2, routes: 12, assets: 2, }) expect(files['render-manifest'].sourceHashes).toStrictEqual(result.sourceHashes) expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) expect(typeof files['render-manifest'].generatedAt).toBe('string') } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-rewrite') try { const { key, siteId } = await seedPublishedSite(db, w) try { const result = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) const files = await readInputFiles(result.inputDir) const news = files.content.find((c: any) => c.slug === 'fixture-news-a') // 库内 published 修订的 bodyHtml(重写前真相):绝对 URL img + 根相对内链 a const dbBody = plain((await db.execute(`select cr.data->>'bodyHtml' as body from content_revisions cr where cr.id = (select published_revision_id from entry_locales el join content_entries ce on ce.id = el.content_entry_id where el.site_id = '${siteId}' and ce.type_key = 'news_article')`)) as any)[0].body expect(dbBody).toContain('https://fixture.example/wp-content/uploads/2026/10/fixture-a.png') // 重写:命中 ready 集合(剥 host 按 path 比对 public_path)→ 根相对 expect(news.data.bodyHtml).toBe( dbBody.replace( 'https://fixture.example/wp-content/uploads/2026/10/fixture-a.png', '/wp-content/uploads/2026/10/fixture-a.png', ), ) expect(news.data.bodyHtml).not.toContain('https://fixture.example/wp-content') // 未命中的根相对内链保留原样(不虚构目标) expect(news.data.bodyHtml).toContain('href="/products/fixture-product/"') // 统计入 render-manifest(rewritten=1 img;unresolved=1 内链 a) expect(files['render-manifest'].urlRewrite).toStrictEqual({ rewritten: 1, unresolved: 1 }) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('canonical JSON 确定性:同输入两次物化 → 六文件字节一致(键排序、无空白)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-det') try { const { siteId } = await seedPublishedSite(db, w) try { const fixedAt = '2026-10-22T00:00:00.000Z' const first = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) const second = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) // 六文件逐字节一致 expect(second.files).toStrictEqual(first.files) // canonical 形态:键递归排序、无空白(文件串 = canonicalJson(解析值) + 尾换行) for (const name of Object.keys(first.files) as Array) { const content = first.files[name] expect(content).toBe(`${canonicalJson(JSON.parse(content))}\n`) expect(content).not.toMatch(/,\s|:\s/) } expect(first.sourceHashes).toStrictEqual(second.sourceHashes) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('NOT_PUBLISHED:未 publish 站点 → 明确报错且零 build_jobs 行;SITE_NOT_FOUND 通道', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-np') try { // register+import 但不 publish const key = `sfbuild-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, mediaDir: join(w.work, 'media'), allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) try { await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) const err = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(err).toBeInstanceOf(BuildError) expect((err as BuildError).code).toBe('NOT_PUBLISHED') // 拒绝发生在任何 job 行之前(快照只读 published——空集无物化意义) expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${reg.siteId}'`)).toBe(0) } finally { await cleanupSiteRows(db, reg.siteId) } const notFound = await createBuildJob(db, { siteKey: 'no-such-site' }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(notFound).toBeInstanceOf(BuildError) expect((notFound as BuildError).code).toBe('SITE_NOT_FOUND') } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 新 key 再 build → content.json 仍为 A(published 冻结)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-adr010') try { const { key, siteId } = await seedPublishedSite(db, w) try { // 第一次 build:published=A 物化 const first = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') }) expect(first.reused).toBe(false) // 改内容再导入:post 101 正文加 V2 标记 → 新修订 B、current=B(published 仍 A) const wxrText = await readFile(WXR_FIXTURE, 'utf8') const v2Path = join(w.work, 'mini-v2.xml') await writeFile(v2Path, wxrText.replace('Body with ', 'V2-BODY Body with '), 'utf8') await registerSource(db, { siteKey: key, wxrPath: v2Path, mediaDir: join(w.work, 'media'), allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) const reimport = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') }) expect(reimport.status).toBe('succeeded_with_issues') // 双指针分叉:current=B ≠ published=A(ADR-010) const forked = plain((await db.execute(`select count(*)::int as n from entry_locales where site_id = '${siteId}' and current_revision_id is distinct from published_revision_id`)) as any)[0].n expect(Number(forked)).toBe(1) // 新幂等键强制新 job(默认键按 published 集合派生会复用——复用语义另测) const second = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts'), idempotencyKey: `build:test:adr010:${rand()}`, }) expect(second.reused).toBe(false) expect(second.jobId).not.toBe(first.jobId) // content.json 仍为 A:字节级等于第一次物化(published 冻结语义) const contentA = await readFile(join(first.inputDir, 'content.json'), 'utf8') const contentB = await readFile(join(second.inputDir, 'content.json'), 'utf8') expect(contentB).toBe(contentA) expect(contentB).not.toContain('V2-BODY') expect(contentB).toContain('fixture-news-a') // 哈希同源:同 published 集合 → content 哈希一致(可复现性锚点) expect(second.sourceHashes.content).toBe(first.sourceHashes.content) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) }) describe('URL 重写器(纯函数:剥 host 按 path 比对 media ready 双源)', () => { const A = '/wp-content/uploads/2026/10/a.png' const index = buildMediaPathIndex([ { publicPath: A, sourceUrl: 'https://old.example.com/wp-content/uploads/2026/10/a.png' }, ]) it('绝对 URL 剥 host 命中 → 根相对 public_path', () => { const r = rewriteBodyHtml('', index) expect(r.html).toBe(``) expect(r.stats).toStrictEqual({ rewritten: 1, unresolved: 0 }) }) it('协议相对与根相对变体同键命中;查询串剥离', () => { for (const url of [ '//old.example.com/wp-content/uploads/2026/10/a.png', `${A}?ver=9`, 'https://old.example.com/wp-content/uploads/2026/10/a.png?ver=9#frag', ]) { const r = rewriteBodyHtml(``, index) expect(r.html).toBe(``) expect(r.stats.rewritten).toBe(1) } }) it('source_url 路径段双源命中(public_path 之外的注册 URL 形态)', () => { const idx = buildMediaPathIndex([{ publicPath: '/files/x.jpg', sourceUrl: 'https://cdn.old.net/files/x.jpg' }]) const r = rewriteBodyHtml('x', idx) expect(r.html).toBe('x') expect(r.stats.rewritten).toBe(1) }) it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => { const r = rewriteBodyHtml( 'ptr', index, ) expect(r.html).toBe( 'ptr', ) expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 }) }) })