From 5c2ee7dc5de1108d50d4b71a0ea9f8e2659bb46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 22 Oct 2026 15:55:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(test):=20=E4=B8=89=E4=BB=BD=20helper?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=85=B1=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 审查遗留①(schema 再演进防漂移):makeWork / seedPublishedSite / cleanupSiteRows 三件套自 build-snapshot / build-pipeline / build-gates / preview-server 四文件(Task 6 审查时三份,Task 7 又落一份)迁 tests/integration/helpers.ts 共享——清场清单(21 语句 FK 反序 + 不可变 触发器开关)此后只改一处。 - makeWork 保持签名;媒体种子载荷改由 prefix 派生(bytes 仍随机唯一, 魔数校验只看 PNG 签名——行为等价);shared Work 统一携带 pngA/pngB - seedPublishedSite 增 keyPrefix 参数(各文件沿用原站点 key 派生前缀 sfbuild/sfpipe/sfgate/sfpreview);succeeded_with_issues 与 publishedCount=3 断言随迁(gallery 203/204 悬空语义文件无关) - import 域短清单清场与 publish 域各自形状不同,不共用——保留原处 零行为变更由 408 测试守护(全绿:根 382 + 主题 26);lint/typecheck 五段过。 Co-Authored-By: Claude Code --- tests/integration/build-gates.test.ts | 76 ++----------------- tests/integration/build-pipeline.test.ts | 81 ++------------------ tests/integration/build-snapshot.test.ts | 97 +++--------------------- tests/integration/helpers.ts | 93 +++++++++++++++++++++++ tests/integration/preview-server.test.ts | 80 +------------------ 5 files changed, 117 insertions(+), 310 deletions(-) diff --git a/tests/integration/build-gates.test.ts b/tests/integration/build-gates.test.ts index 38ad0e1..e7f7896 100644 --- a/tests/integration/build-gates.test.ts +++ b/tests/integration/build-gates.test.ts @@ -21,7 +21,6 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import type { RouteManifest } from '@sf/contracts' import { createBuildJob, BuildError } from '../../apps/api/src/modules/build/build.service.js' @@ -31,13 +30,7 @@ import { GATE_RULE_VERSION, type GateIssue, } from '../../apps/api/src/modules/build/gates.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 { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js' - -const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) -const WXR_FIXTURE = join(FIXTURES_ROOT, 'wxr', 'mini.xml') +import { cleanupSiteRows, makeWork, rand, seedPublishedSite, withTestDb } from './helpers.js' const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const ORIGIN = 'https://gate-fixture.example' @@ -349,73 +342,14 @@ describe('最小门禁(纯产物夹具直调)', () => { }) // ---- mini 全链(DB + 真实 astro 构建——P0 阻断/全过语义) ---- - -interface Work { - work: string - storageRoot: string - allowedRoots: string[] -} - -async function makeWork(prefix: string): Promise { - const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) - const mediaDir = join(work, 'media') - await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) - await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-a-${rand()}`)])) - await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), Buffer.concat([PNG_SIG, Buffer.from(`gate-b-${rand()}`)])) - return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work] } -} - -async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> { - const key = `sfgate-${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') }) - 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 } -} - -/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs)+ 全链反序 */ -async function cleanupSiteRows(db: Db, siteId: string): Promise { - 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}'`) -} +// 三件套(makeWork/seedPublishedSite/cleanupSiteRows)迁 tests/integration/helpers.ts 共享(Task 9 收敛) describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () => { it('任一 P0 → validation_runs FAIL + validation_issues 落库(severity/rule/url/evidence)+ job failed + dist 清场', { timeout: 300_000 }, async () => { await withTestDb(async (db) => { const w = await makeWork('sf-gate-block') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfgate') try { const artifactsRoot = join(w.work, 'artifacts') let blockedJobId = '' @@ -493,7 +427,7 @@ describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () = await withTestDb(async (db) => { const w = await makeWork('sf-gate-batch') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfgate') try { const artifactsRoot = join(w.work, 'artifacts') let batchJobId = '' @@ -536,7 +470,7 @@ describe('P0 阻断语义(mini 全链:门禁接入 build 状态机)', () = await withTestDb(async (db) => { const w = await makeWork('sf-gate-pass') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfgate') try { const statuses: string[] = [] const result = await createBuildJob(db, { diff --git a/tests/integration/build-pipeline.test.ts b/tests/integration/build-pipeline.test.ts index fa5a6e1..4af1bd3 100644 --- a/tests/integration/build-pipeline.test.ts +++ b/tests/integration/build-pipeline.test.ts @@ -26,22 +26,19 @@ import { isRouteManifest, ROUTE_MANIFEST_VERSION } from '@sf/contracts' import { canonicalJson } from '../../apps/api/src/common/canonical.js' import { runBuildPipeline, themeDistDir } from '../../apps/api/src/modules/build/pipeline.js' import { createBuildJob } from '../../apps/api/src/modules/build/build.service.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 { + cleanupSiteRows, createSiteFixture, + makeWork, rand, rejectionMessage, - withImmutableCleanup, + seedPublishedSite, withTestDb, type Db, } from './helpers.js' /** 本文件位于 /tests/integration/ → 上两级是仓库根 */ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') -const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) -const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') const THEME_SRC = join(REPO_ROOT, 'themes', 'pilot-minimal', 'src') const THEME_FIXTURE_INPUT = join(REPO_ROOT, 'themes', 'pilot-minimal', 'test', 'fixtures', 'render-input') @@ -105,78 +102,12 @@ async function scanThemeSrc(): Promise> { return out } -/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→ - * build_jobs;Task 6 起成功构建必落 validation_runs 行)+ 构建域 + 全链反序 */ -async function cleanupSiteRows(db: Db, siteId: string): Promise { - 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 { - const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) - const mediaDir = join(work, 'media') - await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) - const pngA = mediaBytes(`pipe-a-${rand()}`) - const pngB = mediaBytes(`pipe-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 = `sfpipe-${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') }) - 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 } -} - describe('构建管线全链(mini 夹具 DB 物化输入)', () => { it('状态机含 building;dist target_file 权威落盘 + 媒体 sha256 全量 + sitemap/robots + route-manifest §12.1 + build_artifacts 三行', { timeout: 300_000 }, async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pipe-full') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfpipe') try { const statuses: string[] = [] const result = await createBuildJob(db, { @@ -338,7 +269,7 @@ describe('构建管线全链(mini 夹具 DB 物化输入)', () => { await withTestDb(async (db) => { const w = await makeWork('sf-pipe-fail') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfpipe') try { const artifactsRoot = join(w.work, 'artifacts') let failingJobId = '' @@ -598,7 +529,7 @@ describe('五禁断言(主题构建期纪律)', () => { process.env.DATABASE_URL = '' process.env.NODE_OPTIONS = `--import ${pathToFileURL(stub).href}` try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfpipe') try { const result = await createBuildJob(db, { siteKey: key, diff --git a/tests/integration/build-snapshot.test.ts b/tests/integration/build-snapshot.test.ts index 7b09273..6208f1b 100644 --- a/tests/integration/build-snapshot.test.ts +++ b/tests/integration/build-snapshot.test.ts @@ -22,8 +22,7 @@ // 运行于 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 { readFile, rm, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -35,7 +34,6 @@ import { } 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, @@ -47,17 +45,10 @@ import { materializeRenderSnapshot, rewriteBodyHtml, } from '../../apps/api/src/modules/build/render-snapshot.js' -import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js' +import { cleanupSiteRows, makeWork, rand, seedPublishedSite, 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') } @@ -71,74 +62,6 @@ async function countOf(db: Db, sql: string): Promise { return Number(rows[0].n) } -/** 本文件写入的行清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs; -// Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序 */ -async function cleanupSiteRows(db: Db, siteId: string): Promise { - 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 { - 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 } -} - /** 物化结果六文件串 → 解析对象(直调版——spawn 缩减后不走 job 落盘面) */ function parsedFiles(m: { files: Record }): Record { const out: Record = {} @@ -153,7 +76,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-sm') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const statuses: string[] = [] const result = await createBuildJob(db, { @@ -245,7 +168,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-files') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const m = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) // 六文件面齐备(RENDER_SNAPSHOT_FILES 冻结清单——物化产物键即清单) @@ -384,7 +307,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-rewrite') try { - const { siteId } = await seedPublishedSite(db, w) + const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { 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') @@ -420,7 +343,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-det') try { - const { siteId } = await seedPublishedSite(db, w) + const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const fixedAt = '2026-10-22T00:00:00.000Z' const first = await materializeRenderSnapshot(db, { siteId, generatedAt: fixedAt }) @@ -491,7 +414,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-adr010') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 第一次物化:published=A const first = await materializeRenderSnapshot(db, { siteId, generatedAt: '2026-10-22T00:00:00.000Z' }) @@ -539,7 +462,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-dup') try { - const { siteId } = await seedPublishedSite(db, w) + const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { const aPub = '/wp-content/uploads/2026/10/fixture-a.png' const bPub = '/wp-content/uploads/2026/10/fixture-b.png' @@ -579,7 +502,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-lineage') try { - const { key, siteId } = await seedPublishedSite(db, w) + const { key, siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 手构崩溃残留:上个进程中断留下的 snapshotting 悬行(默认键、attempt=1) const pubIds = plain( @@ -641,7 +564,7 @@ describe('build job + Render Snapshot 物化', () => { await withTestDb(async (db) => { const w = await makeWork('sf-build-nonready') try { - const { siteId } = await seedPublishedSite(db, w) + const { siteId } = await seedPublishedSite(db, w, 'sfbuild') try { // 登记后状态翻转:fixture-b(product 的 featured 引用)→ quarantined await db.execute(`update media_assets set status = 'quarantined' diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 045974e..173790e 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -2,7 +2,15 @@ // 收敛纪律:只抽"每个文件逐字重复"的实现——随机后缀、建 site、不可变表清场开关、 // 拒绝断言解包、连接生命周期;各文件的断言、对照组、清场范围与顺序保持不变。 // 注:各 withXFixture 的 setup 阶段中途失败不清理已插半的行——该盲区已被 ADR-015 临时库整库 DROP 实质中和,属已接受设计。 +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect } from 'vitest' import { getDb } from '../../apps/api/src/db/client.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' export type Db = ReturnType @@ -68,3 +76,88 @@ export async function withTestDb(fn: (db: Db) => Promise): Promise { await (db as any).$client.end() } } + +// ---- Plan 4 全链夹具(Task 9 收敛——Task 6 审查遗留①) ---- +// build-snapshot / build-pipeline / build-gates / preview-server 四个文件内联重复的 +// 三件套(makeWork / seedPublishedSite / cleanupSiteRows)抽此共享——schema 再演进时 +// 清场清单只改一处(防漂移)。纪律同上:只抽重复件,各文件的断言与对照组不变 +// (零行为变更由 408 测试守护)。import 域的短清单清场(无 validation/build 行)与 +// publish 域各自的 cleanupSiteRows 形状不同,不共用——保留在各文件。 + +const PLAN4_FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) +const PLAN4_WXR_FIXTURE = resolve(PLAN4_FIXTURES_ROOT, 'wxr', 'mini.xml') +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +/** 伪 PNG 体(魔数校验只看签名——载荷唯一避免跨测试撞哈希;png-body 前缀与 import 域夹具同款) */ +function pngBytes(seed: string): Buffer { + return Buffer.concat([PNG_SIG, Buffer.from(`png-body:${seed}`, 'latin1')]) +} + +/** Plan 4 工作目录夹具:tmp 工作目录 + 旧 uploads 层级两枚 PNG(mini.xml 媒体引用精确命中 → ready 两行) */ +export interface Work { + work: string + storageRoot: string + allowedRoots: string[] + pngA: Buffer + pngB: Buffer +} + +export async function makeWork(prefix: string): Promise { + const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) + const mediaDir = join(work, 'media') + await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) + const pngA = pngBytes(`${prefix}-a-${rand()}`) + const pngB = pngBytes(`${prefix}-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: [PLAN4_FIXTURES_ROOT, work], pngA, pngB } +} + +/** mini 全链(register wxr+media → import → publish):3 条 published + 2 ready 媒体。 + * keyPrefix 由调用方给出(沿用各文件原站点 key 派生前缀);gallery 203/204 悬空 → + * issues 非空是预期(§7.6 禁静默——succeeded_with_issues 断言内联于此)。 */ +export async function seedPublishedSite(db: Db, w: Work, keyPrefix: string): Promise<{ key: string; siteId: string }> { + const key = `${keyPrefix}-${rand()}` + const reg = await registerSource(db, { + siteKey: key, + wxrPath: PLAN4_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') }) + 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 } +} + +/** Plan 4 全链清场:validation 域先行(FK 链 validation_issues→validation_runs→build_jobs; + * Task 6 起成功构建必落 validation_runs 行)+ 构建域(不可变快照先行)+ 全链反序。 */ +export async function cleanupSiteRows(db: Db, siteId: string): Promise { + 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}'`) +} diff --git a/tests/integration/preview-server.test.ts b/tests/integration/preview-server.test.ts index 69098a7..10500dc 100644 --- a/tests/integration/preview-server.test.ts +++ b/tests/integration/preview-server.test.ts @@ -20,99 +20,25 @@ // 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout import { execFile, spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { request } from 'node:http' -import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { RouteManifest, RouteManifestRoute } from '@sf/contracts' import { createBuildJob } from '../../apps/api/src/modules/build/build.service.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 { getDb } from '../../apps/api/src/db/client.js' -import { rand, withImmutableCleanup, type Db } from './helpers.js' +import { cleanupSiteRows, makeWork, seedPublishedSite, type Db, type Work } from './helpers.js' import { createPreviewServer, type PreviewServerHandle } from '../../apps/preview-server/server.mjs' /** 本文件位于 /tests/integration/ → 上两级是仓库根 */ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') -const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) -const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') const TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs') -const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -function mediaBytes(seed: string): Buffer { - return Buffer.concat([PNG_SIG, Buffer.from(`media-body:${seed}`, 'latin1')]) -} function sha256of(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex') } -interface Work { - work: string - storageRoot: string - allowedRoots: string[] - pngA: Buffer -} - -async function makeWork(prefix: string): Promise { - const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) - const mediaDir = join(work, 'media') - await mkdir(join(mediaDir, '2026', '10'), { recursive: true }) - const pngA = mediaBytes(`preview-a-${rand()}`) - await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA) - await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), mediaBytes(`preview-b-${rand()}`)) - return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA } -} - -/** mini 全链(register wxr+media → import → publish):3 条 published(build-pipeline 同款) */ -async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> { - const key = `sfpreview-${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') }) - 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 } -} - -/** 本文件写入的行清场(build-pipeline 同款反序:validation 域先行 + 构建域 + 全链) */ -async function cleanupSiteRows(db: Db, siteId: string): Promise { - 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}'`) -} - /** 原样路径请求(fetch/URL 会规范化 '/../' 等点段——穿越用例必须走 node:http 裸发) */ function rawRequest( port: number, @@ -142,7 +68,7 @@ describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () beforeAll(async () => { db = getDb() work = await makeWork('sf-preview') - const seed = await seedPublishedSite(db, work) + const seed = await seedPublishedSite(db, work, 'sfpreview') siteId = seed.siteId const result = await createBuildJob(db, { siteKey: seed.key,