feat(build): 冻结 Render Snapshot 物化与 build job

Plan 4 Task 2:build_jobs 生命周期(queued→snapshotting→validating(空)→succeeded)
+ build_input_snapshots 行(三哈希/主题/config 引用/真实版本列)+ 六文件物化
(site/content/taxonomies/routes/assets/render-manifest——canonicalJson 确定性落盘)。

- render-snapshot.ts:纯物化(db 只读)——published 修订(ADR-010 冻结语义)+
  路由/媒体 ready 集合 → 六文件;bodyHtml URL 重写(剥 host 按 path 与 ready 集合
  public_path/source_url 双源比对,命中根相对化、未命中保留+计数);媒体实体路径
  三候选解析(快照 storageRoot→仓库存储根→incoming 兜底)
- build.service.ts:幂等键 = site+published 集合哈希+主题版本(复用不重跑);
  repeatable read 单事务一致性读取+落盘+快照行;报告 artifacts/jobs/<id>/reports/
  build.json 先于终态落行;解析期失败零 job 行(NOT_PUBLISHED/SITE_NOT_FOUND)
- sf.ts + build:create;build.module.ts Nest 薄壳
- tests/integration/build-snapshot.test.ts:10 测试(状态机/六文件面/URL 重写/
  字节确定性/NOT_PUBLISHED/ADR-010 双修订/重写器纯函数四例)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 07:46:23 +08:00
parent 6d681f24ef
commit 8f42c838c1
5 changed files with 1575 additions and 6 deletions
+583
View File
@@ -0,0 +1,583 @@
// 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/<jobId>/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<number> {
const rows = (await db.execute(sql)) as any
return Number(rows[0].n)
}
/** 本文件写入的行清场:构建域(不可变快照先行)+ 发布/导入/来源全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
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<Work> {
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<Record<string, any>> {
const names = ['site', 'content', 'taxonomies', 'routes', 'assets', 'render-manifest']
const out: Record<string, any> = {}
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<keyof typeof first.files>) {
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('<img src="https://old.example.com/wp-content/uploads/2026/10/a.png"/>', index)
expect(r.html).toBe(`<img src="${A}"/>`)
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(`<img src="${url}"/>`, index)
expect(r.html).toBe(`<img src="${A}"/>`)
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('<a href="https://cdn.old.net/files/x.jpg">x</a>', idx)
expect(r.html).toBe('<a href="/files/x.jpg">x</a>')
expect(r.stats.rewritten).toBe(1)
})
it('未命中(含外域)保留原样 + unresolved 计数;data:/片段/相对形态不计', () => {
const r = rewriteBodyHtml(
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
index,
)
expect(r.html).toBe(
'<a href="https://cdn.other.example.com/pixel.png">p</a><img src="data:image/png;base64,AAA"/><a href="#top">t</a><a href="page.html">r</a>',
)
expect(r.stats).toStrictEqual({ rewritten: 0, unresolved: 1 })
})
})