Files
cms-website/tests/integration/build-snapshot.test.ts
T

720 lines
38 KiB
TypeScript
Raw Normal View History

// Plan 4 Task 2 失败测试(先于实现):Render Snapshot 物化 + build job(ADR-011、V2 spec §9)。
// 覆盖契约(对照计划 Task 2):
// 1. build job 状态机:queued→snapshotting→building(Task 5 真实构建)→validating
2026-10-22 12:29:48 +08:00
// (Task 6 最小门禁)→succeeded +
// build_input_snapshots 行(content/route/asset 三哈希 = 各自**文件字节**(canonicalJson+
// 尾换行)的 sha256——哈希链与落盘文件互证、site_config_revision_id 指向 config v1、
// theme_key='pilot-minimal'、node_version 等版本信息列真实读 process.versions)+
// 幂等键复用(同 published 集合 → reused 不重跑;只有 succeeded 可复用——failed/
// 崩溃残留行走 stale 宣判+#a 后缀新行自动恢复);
// 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,
defaultBuildIdempotencyKey,
} 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)
}
2026-10-22 12:29:48 +08:00
/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs;
// Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序 */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
2026-10-22 12:29:48 +08:00
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
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 }
}
2026-10-22 12:29:48 +08:00
/** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */
function parsedFiles(m: { files: Record<string, string> }): Record<string, any> {
const out: Record<string, any> = {}
2026-10-22 12:29:48 +08:00
for (const [name, content] of Object.entries(m.files)) {
out[name.replace(/\.json$/, '')] = JSON.parse(content)
}
return out
}
describe('build job + Render Snapshot 物化', () => {
2026-10-22 12:29:48 +08:00
it('状态机 queued→snapshotting→building→validating(门禁)→succeeded + build_input_snapshots 行(三哈希/主题/真实版本列)+ 幂等复用', { timeout: 300_000 }, 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')
2026-10-22 12:29:48 +08:00
// 状态机序列:snapshotting 后进 building(真实 astro 构建管线)→ validating
// (Task 6 最小门禁——mini 夹具产物过五门禁)→ succeeded
expect(statuses).toStrictEqual(['queued', 'snapshotting', 'building', '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)
// 三哈希独立可复算:sha256(文件字节含尾换行)——哈希链与落盘文件互证(审查 Fix 1)
for (const [col, file] of [
['content_snapshot_hash', 'content.json'],
['route_snapshot_hash', 'routes.json'],
['asset_manifest_hash', 'assets.json'],
] as const) {
const raw = await readFile(join(result.inputDir, file), 'utf8')
expect(s[col]).toBe(sha256of(raw))
}
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 })
}
})
})
2026-10-22 12:29:48 +08:00
it('六文件物化:site/config v1、content(published+media)、taxonomies(source_id 原样)、routes(redirect 并入+查询形态)、assets(实体路径解析)、render-manifest(版本块+计数+哈希)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-files')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
2026-10-22 12:29:48 +08:00
const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// 六文件面齐备(RENDER_SNAPSHOT_FILES 冻结清单——物化产物键即清单)
expect(Object.keys(m.files).sort()).toStrictEqual(
['assets.json', 'content.json', 'render-manifest.json', 'routes.json', 'site.json', 'taxonomies.json'],
)
const files = parsedFiles(m)
// 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,
})
2026-10-22 12:29:48 +08:00
expect(files['render-manifest'].sourceHashes).toStrictEqual(m.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 })
}
})
})
2026-10-22 12:29:48 +08:00
it('bodyHtml URL 重写:media ready 命中 → 根相对 public_path;未命中保留原样;render-manifest 统计【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-rewrite')
try {
2026-10-22 12:29:48 +08:00
const { siteId } = await seedPublishedSite(db, w)
try {
2026-10-22 12:29:48 +08:00
const files = parsedFiles(await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }))
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 })
}
})
})
2026-10-22 12:29:48 +08:00
it('ADR-010 双修订:发布 A → 再导入改内容 current=B → 再物化 → content.json 仍为 A(published 冻结)【materializeRenderSnapshot 直调——spawn 缩减】', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-adr010')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
2026-10-22 12:29:48 +08:00
// 第一次物化:published=A
const first = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// 改内容再导入: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)
2026-10-22 12:29:48 +08:00
// 再物化(等价于"新 key 再 build"的输入面——job/幂等键语义在谱系测试覆盖)
const second = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
// content.json 仍为 A:字节级等于第一次物化(published 冻结语义)
2026-10-22 12:29:48 +08:00
const contentA = first.files['content.json']
const contentB = second.files['content.json']
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 })
}
})
})
it('media 块确定性:同 fieldPath 多资产引用 → first-wins 单值 + issue;两次物化字节一致(审查 Fix 2)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-dup')
try {
const { siteId } = await seedPublishedSite(db, w)
try {
const aPub = '/wp-content/uploads/2026/10/fixture-a.png'
const bPub = '/wp-content/uploads/2026/10/fixture-b.png'
// 数据面异常注入:news published 修订的 featured 字段再挂 fixture-b
//(media_usages PK = 资产×修订×fieldPath——跨资产同 fieldPath 可重)
await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url)
select '${siteId}',
(select id from media_assets where site_id = '${siteId}' and public_path = '${bPub}'),
(select el.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'),
'attachmentRefs.featured',
'https://fixture.example/wp-content/uploads/2026/10/fixture-b.png'`)
const fixedAt = '2026-10-22T00:00:00.000Z'
const m1 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt })
const m2 = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt })
const content1 = JSON.parse(m1.files['content.json'])
const news = content1.find((c: any) => c.slug === 'fixture-news-a')
// first-wins:orderBy(fieldPath, publicPath) 字典序首位 = fixture-a 稳定胜出
expect(news.media['attachmentRefs.featured']).toBe(aPub)
expect(
m1.issues.some((i) => i.includes('同 fieldPath 多资产引用') && i.includes('attachmentRefs.featured')),
).toBe(true)
// 两次物化字节一致 + issue 面一致(first-wins 确定性)
expect(m2.files['content.json']).toBe(m1.files['content.json'])
expect(m2.issues).toStrictEqual(m1.issues)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
2026-10-22 12:29:48 +08:00
it('job 谱系:崩溃残留 stale 宣判 → #a2 落败不复用 → #a3 重跑成功(复用只认 succeeded;审查 Fix 3 两场景并谱——spawn 缩减共享单次成功构建)', { timeout: 300_000 }, async () => {
await withTestDb(async (db) => {
2026-10-22 12:29:48 +08:00
const w = await makeWork('sf-build-lineage')
try {
const { key, siteId } = await seedPublishedSite(db, w)
try {
2026-10-22 12:29:48 +08:00
// 手构崩溃残留:上个进程中断留下的 snapshotting 悬行(默认键、attempt=1)
const pubIds = plain(
(await db.execute(`select published_revision_id from entry_locales
where site_id = '${siteId}' and published_revision_id is not null`)) as any,
).map((r: any) => r.published_revision_id)
const defKey = defaultBuildIdempotencyKey(key, pubIds)
await db.execute(`insert into build_jobs (site_id, status, idempotency_key, attempt, started_at)
values ('${siteId}', 'snapshotting', '${defKey}', 1, now())`)
// 撞悬行跑失败:残留行宣判 stale 让位 → #a2 新行;再迫使快照落盘失败
// (artifactsRoot 指到普通文件之下 → mkdir ENOTDIR)
const blocker = join(w.work, 'blocker')
await writeFile(blocker, 'x', 'utf8')
const err: unknown = await createBuildJob(db, {
siteKey: key,
artifactsRoot: join(blocker, 'artifacts'),
}).then(
() => {
throw new Error('不应成功')
},
(e: unknown) => e,
)
expect(err).toBeInstanceOf(Error)
2026-10-22 12:29:48 +08:00
// 默认键重跑(好根):default → stale(终态死行)→ #a2 → failed(终态死行)
// → #a3 新行成功——failed 不复用、attempt 延续(重试谱系)
const rerun = await createBuildJob(db, { siteKey: key, artifactsRoot: join(w.work, 'artifacts') })
expect(rerun.reused).toBe(false)
expect(rerun.status).toBe('succeeded')
2026-10-22 12:29:48 +08:00
expect(await countOf(db, `select count(*)::int as n from build_jobs where site_id = '${siteId}'`)).toBe(3)
const rows = plain(
2026-10-22 12:29:48 +08:00
(await db.execute(`select status, idempotency_key, attempt, error_code from build_jobs
where site_id = '${siteId}' order by attempt`)) as any,
)
2026-10-22 12:29:48 +08:00
// 谱系三行:stale(宣判)→ failed(SNAPSHOT_FAILED 保持终态)→ succeeded(#a3)
expect(rows[0].status).toBe('stale')
expect(rows[0].idempotency_key).toBe(defKey)
2026-10-22 12:29:48 +08:00
expect(Number(rows[0].attempt)).toBe(1)
expect(rows[1].status).toBe('failed')
expect(rows[1].error_code).toBe('SNAPSHOT_FAILED')
expect(String(rows[1].idempotency_key)).toBe(`${defKey}#a2`)
expect(Number(rows[1].attempt)).toBe(2)
expect(rows[2].status).toBe('succeeded')
expect(String(rows[2].idempotency_key)).toBe(`${defKey}#a3`)
expect(Number(rows[2].attempt)).toBe(3)
// 新 job 物化完整
2026-10-22 12:29:48 +08:00
expect(existsSync(join(rerun.inputDir, 'render-manifest.json'))).toBe(true)
} finally {
await cleanupSiteRows(db, siteId)
}
} finally {
await rm(w.work, { recursive: true, force: true })
}
})
})
it('media 引用非 ready 资产:引用不进 media 块 + issue 如实报告;assets 面同步收窄(审查 Fix 6)', async () => {
await withTestDb(async (db) => {
const w = await makeWork('sf-build-nonready')
try {
const { siteId } = await seedPublishedSite(db, w)
try {
// 登记后状态翻转:fixture-b(product 的 featured 引用)→ quarantined
await db.execute(`update media_assets set status = 'quarantined'
where site_id = '${siteId}' and public_path = '/wp-content/uploads/2026/10/fixture-b.png'`)
const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' })
const content = JSON.parse(m.files['content.json'])
const product = content.find((c: any) => c.slug === 'fixture-product')
// 引用不进 media 块(product 的 featured 是 fixture-b)+ issue 如实报告
expect(product.media).toBeUndefined()
expect(
m.issues.some(
(i) => i.includes('非 ready') && i.includes('attachmentRefs.featured') && i.includes('quarantined'),
),
).toBe(true)
// ready 集合同步收窄:assets 面只剩 fixture-a
const assets = JSON.parse(m.files['assets.json'])
expect(assets).toHaveLength(1)
expect(assets[0].publicPath).toBe('/wp-content/uploads/2026/10/fixture-a.png')
} 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:/片段/相对形态不计;连字属性不卷入(审查 Fix 5)', () => {
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 })
// 边界锚定:data-src 的 URL 即使命中索引也不改写不计(懒加载属性是渲染域决策)
//(宽松 \b 前缀会把 data-src 的 '-s' 交界误判为词边界——此用例钉死收紧语义)
const lazy = rewriteBodyHtml(`<img data-src="https://old.example.com${A}"/>`, index)
expect(lazy.html).toBe(`<img data-src="https://old.example.com${A}"/>`)
expect(lazy.stats).toStrictEqual({ rewritten: 0, unresolved: 0 })
})
})