Files
浅念 5c2ee7dc5d refactor(test): 三份 helper 迁移共享
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 <noreply@anthropic.com>
2026-10-22 15:55:43 +08:00

164 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 集成测试公共夹具(Task 13 收敛):Task 6–12 七个域测试文件的内联重复件抽此共享。
// 收敛纪律:只抽"每个文件逐字重复"的实现——随机后缀、建 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<typeof getDb>
/** 随机后缀:毫秒时间戳 + 6 位随机,同毫秒并发行也可区分;所有 fixture 的唯一性来源。 */
export function rand(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言触发器/约束/枚举身份时必须看 cause。
export async function rejectionMessage(db: Db, query: string): Promise<string> {
try {
await db.execute(query)
} catch (e: any) {
return String(e?.cause?.message ?? e?.message ?? e)
}
throw new Error(`expected query to be rejected: ${query}`)
}
/**
* 站点夹具:随机 key 建 site,返回 id + 清理句柄。
* 清理句柄只删 site 行——调用方必须先删完该站点的子表行再调用(FK 依赖顺序由各域自查)。
* name/origin 是不参与任何断言的装饰列,统一按 prefix 派生。
*/
export type SiteFixture = { siteId: string; key: string; rand: string; cleanup: () => Promise<void> }
export async function createSiteFixture(db: Db, prefix: string): Promise<SiteFixture> {
const r = rand()
const key = `${prefix}-${r}`
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin)
values (gen_random_uuid(), '${key}', '${prefix} fixture', 'en', 'https://${prefix}.example') returning id`)) as any
const siteId = site[0].id
return {
siteId,
key,
rand: r,
cleanup: async () => {
await db.execute(`delete from sites where id = '${siteId}'`)
},
}
}
/**
* 不可变表清场封装:DISABLE 触发器 → 删行 → ENABLE(嵌套 finally 保证触发器必然恢复)。
* 不可变表的生产语义是永不删;测试清场是唯一例外,删除范围由调用方谓词圈定。
* @param where 完整 WHERE 谓词(域测试按 site_id 圈;audit_logs 组织级行按 command_id 圈)
*/
export async function withImmutableCleanup(db: Db, table: string, trigger: string, where: string): Promise<void> {
await db.execute(`alter table ${table} disable trigger ${trigger}`)
try {
await db.execute(`delete from ${table} where ${where}`)
} finally {
await db.execute(`alter table ${table} enable trigger ${trigger}`)
}
}
/** 连接生命周期封装:每个测试持有独立 getDb 池,结束必关(清场先于关池执行)。 */
export async function withTestDb(fn: (db: Db) => Promise<void>): Promise<void> {
const db = getDb()
try {
await fn(db)
} finally {
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<Work> {
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<void> {
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}'`)
}