5c2ee7dc5d
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>
164 lines
8.7 KiB
TypeScript
164 lines
8.7 KiB
TypeScript
// 集成测试公共夹具(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}'`)
|
||
}
|