Files
cms-website/tests/integration/build-snapshot.test.ts
T
浅念 8f42c838c1 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>
2026-10-22 07:46:23 +08:00

584 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Plan 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 })
})
})