8f42c838c1
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>
584 lines
30 KiB
TypeScript
584 lines
30 KiB
TypeScript
// 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 })
|
||
})
|
||
})
|