test(db): 空库一键迁移验收 + globalSetup 临时库 + 公共夹具收敛(阶段1验收)

- vitest globalSetup:每次运行 CREATE sf_test_<ts> 临时库 → drizzle-orm 程序化迁移器
  全量迁移(apps/api/src/db/migrate.ts,drizzle-kit 无法指向运行时建库)→ 设
  DATABASE_URL_TEST 让 getDb() 自动回退到临时库;teardown DROP(WITH FORCE)。
  有 default 导出时 vitest 不捕获具名 teardown,故用双具名导出。
- db.full-migrate.test.ts:21 表齐 + 四张不可变触发器在库 + sf_test_* 护栏断言
  (current_database 不符即红,杜绝静默连开发库)。
- helpers.ts 收敛:rand/rejectionMessage/createSiteFixture/withImmutableCleanup/
  withTestDb,Task 6–12 七文件内联版替换为 import(断言、对照组、清场顺序不变)。
- scripts/migrate-test.sh:docker compose up --wait + pnpm test 一键验收。
- 实证:75 测试全绿(72+3);开发库 21 表行数总和 0(零写入);无 sf_test_* 残留。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 20:43:13 +08:00
parent 9e0b49885b
commit f1c181de02
13 changed files with 338 additions and 220 deletions
+53
View File
@@ -0,0 +1,53 @@
// 程序化数据库供给与迁移(Task 13):drizzle-kit migrate 只认 drizzle.config 的
// DATABASE_URL(requireDatabaseUrl allowTestUrl:false),无法指向运行时才创建的
// 临时库——vitest globalSetup 用本模块对 sf_test_* 临时库完成 CREATE + 全量迁移 + DROP。
// 驱动依赖(postgres / drizzle-orm)必须从本包解析:仓库根 node_modules 不含它们,
// tests/ 下的调用方无法裸 import,故统一收口在本文件。
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
// 本文件位于 apps/api/src/db/ → 上两级是 apps/api/,迁移产物在其 drizzle/ 子目录
const defaultMigrationsFolder = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'drizzle')
// CREATE/DROP DATABASE 不能走参数绑定(且本身是 DDL),名称先做白名单校验再内插。
function assertSafeName(name: string): void {
if (!/^[a-z0-9_]+$/i.test(name)) throw new Error(`unsafe database name: ${name}`)
}
/** 对显式连接串的目标库执行 apps/api/drizzle 下的全量迁移(空库 → 最新 schema)。 */
export async function migrateDatabase(
connectionString: string,
migrationsFolder: string = defaultMigrationsFolder,
): Promise<void> {
const sql = postgres(connectionString, { max: 1 })
try {
await migrate(drizzle(sql), { migrationsFolder })
} finally {
await sql.end()
}
}
/** 在 adminUrl(指向 postgres 维护库的连接串)所标识的集群里创建新数据库。 */
export async function createDatabase(adminConnectionString: string, name: string): Promise<void> {
assertSafeName(name)
const sql = postgres(adminConnectionString, { max: 1 })
try {
await sql.unsafe(`create database "${name}"`)
} finally {
await sql.end()
}
}
/** 删除数据库。WITH (FORCE)(PG 13+)先断开目标库上的残余业务连接再删——测试池泄漏时不卡 teardown。 */
export async function dropDatabase(adminConnectionString: string, name: string): Promise<void> {
assertSafeName(name)
const sql = postgres(adminConnectionString, { max: 1 })
try {
await sql.unsafe(`drop database if exists "${name}" with (force)`)
} finally {
await sql.end()
}
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 一键空库迁移验收(Task 13):确保 docker PG 在跑 → 跑全量测试。
# vitest globalSetup 每次运行会新建 sf_test_<ts> 临时库、跑全量迁移、结束 DROP——
# 开发库(siteforge)零写入,无需预迁移。
# --wait:等 PG 容器 healthcheck 就绪再继续,避免冷启动期连接被拒。
set -euo pipefail
cd "$(dirname "$0")/.."
docker compose -f infra/docker-compose.yml up -d --wait
pnpm test
+18 -40
View File
@@ -1,11 +1,11 @@
// 内容域集成测试:修订不可变触发器(UPDATE/DELETE 拒绝)+ entry_locales 三元组唯一 + // 内容域集成测试:修订不可变触发器(UPDATE/DELETE 拒绝)+ entry_locales 三元组唯一 +
// 双指针语义(发布=只动 entry_locales 指针,不碰修订行)+ source_records.content_entry_id 回填 FK。 // 双指针语义(发布=只动 entry_locales 指针,不碰修订行)+ source_records.content_entry_id 回填 FK。
// 生命周期纪律:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots) // Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// 清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 // 生命周期纪律不变:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots)
// 清场走 DISABLE→删→ENABLE 封装,嵌套 finally 保证触发器必然恢复。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { type Fixture = {
siteId: string siteId: string
entryId: string entryId: string
@@ -19,10 +19,9 @@ type Fixture = {
// 建链(FK 依赖顺序):site → content_entry → content_revision → entry_locale(current 指针指向该修订,published=NULL); // 建链(FK 依赖顺序):site → content_entry → content_revision → entry_locale(current 指针指向该修订,published=NULL);
// 另建 source 链(connection → snapshot → job)供 source_records FK 探针使用。 // 另建 source 链(connection → snapshot → job)供 source_records FK 探针使用。
async function setupContentFixture(db: Db): Promise<Fixture> { async function setupContentFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'content')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) const rand = site.rand
values (gen_random_uuid(), 'content-${rand}', 'ContentFixture', 'en', 'https://content.example') returning id`)) as any const siteId = site.siteId
const siteId = site[0].id
const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status) const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status)
values (gen_random_uuid(), '${siteId}', 'post', 'active') returning id`)) as any values (gen_random_uuid(), '${siteId}', 'post', 'active') returning id`)) as any
const entryId = entry[0].id const entryId = entry[0].id
@@ -41,48 +40,27 @@ async function setupContentFixture(db: Db): Promise<Fixture> {
return { siteId, entryId, localeId: locale[0].id, revisionId, connectionId, snapshotId: snap[0].id, importJobId: job[0].id } return { siteId, entryId, localeId: locale[0].id, revisionId, connectionId, snapshotId: snap[0].id, importJobId: job[0].id }
} }
// 反序清理。source_records 先于 content_entries(新 FK);两张不可变表逐一 DISABLE → 删 → ENABLE(嵌套 finally)。 // 反序清理。source_records 先于 content_entries(新 FK);两张不可变表逐一 DISABLE→删→ENABLE。
async function cleanupContentFixture(db: Db, f: Fixture) { async function cleanupContentFixture(db: Db, f: Fixture) {
await db.execute(`delete from source_records where site_id = '${f.siteId}'`) await db.execute(`delete from source_records where site_id = '${f.siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${f.siteId}'`) await db.execute(`delete from entry_locales where site_id = '${f.siteId}'`)
await db.execute(`alter table content_revisions disable trigger content_revisions_immutable`) await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${f.siteId}'`)
try {
await db.execute(`delete from content_revisions where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table content_revisions enable trigger content_revisions_immutable`)
}
await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`) await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`)
await db.execute(`alter table source_snapshots disable trigger source_snapshots_immutable`) await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${f.siteId}'`)
try {
await db.execute(`delete from source_snapshots where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table source_snapshots enable trigger source_snapshots_immutable`)
}
await db.execute(`delete from source_connections where site_id = '${f.siteId}'`) await db.execute(`delete from source_connections where site_id = '${f.siteId}'`)
await db.execute(`delete from content_entries where site_id = '${f.siteId}'`) await db.execute(`delete from content_entries where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`) await db.execute(`delete from sites where id = '${f.siteId}'`)
} }
async function withContentFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withContentFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupContentFixture(db) const f = await setupContentFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupContentFixture(db, f) await cleanupContentFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言触发器/约束身份时必须看 cause。
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}`)
} }
describe('内容域约束(修订不可变 + 双指针)', () => { describe('内容域约束(修订不可变 + 双指针)', () => {
+47
View File
@@ -0,0 +1,47 @@
// 空库全量迁移验收(Task 13 / 阶段 1 收官):globalSetup 对 sf_test_<ts> 临时库跑
// apps/api/drizzle 全量迁移(0000–0006),本文件在空迁移后的临时库上断言:
// 1. 21 张域表全部存在(数量恰为 21——既防缺表也防多表);
// 2. 四张不可变表的触发器在库;
// 3. 护栏:current_database() 必须是 sf_test_*(globalSetup 生效证明 + 绝不静默连开发库)。
// 全文件零写入——验收的是迁移产物本身。
import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js'
const EXPECTED_TABLES = [
'sites', 'site_config_revisions',
'source_connections', 'source_snapshots', 'import_jobs', 'source_records',
'content_entries', 'entry_locales', 'content_revisions',
'taxonomies', 'terms', 'content_terms',
'media_assets', 'media_usages',
'route_registry',
'build_jobs', 'build_input_snapshots', 'build_artifacts', 'validation_runs', 'validation_issues',
'audit_logs',
]
describe('空库全量迁移', () => {
it('一键迁移后 21 张核心表全部存在', async () => {
const db = getDb()
const r = await db.execute(`select table_name from information_schema.tables where table_schema='public'`)
const tables = (r as any[]).map((x) => (x as any).table_name)
for (const t of EXPECTED_TABLES) expect(tables).toContain(t)
expect(tables.filter((t: string) => EXPECTED_TABLES.includes(t)).length).toBe(21)
})
it('四张不可变表的触发器在库', async () => {
const db = getDb()
const r = await db.execute(`select trigger_name from information_schema.triggers where trigger_schema='public'`)
const names = new Set((r as any[]).map((x) => (x as any).trigger_name))
for (const t of ['source_snapshots_immutable', 'content_revisions_immutable', 'build_input_snapshots_immutable', 'audit_logs_immutable']) {
expect(names.has(t)).toBe(true)
}
})
it('护栏:套件运行在 globalSetup 创建的 sf_test_* 临时库上,绝不连开发库', async () => {
const db = getDb()
const r = (await db.execute(`select current_database() as db`)) as any
// globalSetup 未生效时此断言失败(current_database 是开发库 siteforge)——
// 宁可红也不让集成测试静默读写开发库。
expect(r[0].db).toMatch(/^sf_test_\d+$/)
expect(process.env.DATABASE_URL_TEST ?? '').toMatch(/^postgres:\/\/.*sf_test_\d+$/)
})
})
+18 -40
View File
@@ -1,13 +1,13 @@
// 构建/验收/审计域集成测试(0006 迁移):build_jobs 幂等键唯一 + 状态 enum 八值、 // 构建/验收/审计域集成测试(0006 迁移):build_jobs 幂等键唯一 + 状态 enum 八值、
// build_input_snapshots 不可变触发器 + build_job_id UNIQUE、build_artifacts 三元组唯一、 // build_input_snapshots 不可变触发器 + build_job_id UNIQUE、build_artifacts 三元组唯一、
// audit_logs 追加式(UPDATE/DELETE 拒绝)、validation_issues.severity 可写。 // audit_logs 追加式(UPDATE/DELETE 拒绝)、validation_issues.severity 可写。
// 生命周期纪律:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots / // Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// audit_logs)清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 // 生命周期纪律不变:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots /
// audit_logs)清场走 DISABLE→删→ENABLE 封装。
// audit_logs 组织级行(site_id NULL,spec §5.8)无 site 可挂——清场按 fixture 唯一 command_id 圈定。 // audit_logs 组织级行(site_id NULL,spec §5.8)无 site 可挂——清场按 fixture 唯一 command_id 圈定。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { type Fixture = {
siteId: string siteId: string
buildJobId: string buildJobId: string
@@ -20,10 +20,9 @@ type Fixture = {
// 建链(FK 依赖顺序):site → build_job → build_input_snapshot;validation_run 挂 site+job; // 建链(FK 依赖顺序):site → build_job → build_input_snapshot;validation_run 挂 site+job;
// audit_logs 挂 site(站点级行;组织级 NULL 行在测试体内插,由 command_id 清场兜底)。 // audit_logs 挂 site(站点级行;组织级 NULL 行在测试体内插,由 command_id 清场兜底)。
async function setupJobsFixture(db: Db): Promise<Fixture> { async function setupJobsFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'jobs')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) const rand = site.rand
values (gen_random_uuid(), 'jobs-${rand}', 'JobsFixture', 'en', 'https://jobs.example') returning id`)) as any const siteId = site.siteId
const siteId = site[0].id
const job = (await db.execute(`insert into build_jobs (id, site_id, status, idempotency_key, attempt) const job = (await db.execute(`insert into build_jobs (id, site_id, status, idempotency_key, attempt)
values (gen_random_uuid(), '${siteId}', 'queued', 'build-idem-${rand}', 0) returning id, idempotency_key`)) as any values (gen_random_uuid(), '${siteId}', 'queued', 'build-idem-${rand}', 0) returning id, idempotency_key`)) as any
const buildJobId = job[0].id const buildJobId = job[0].id
@@ -45,48 +44,27 @@ async function setupJobsFixture(db: Db): Promise<Fixture> {
} }
} }
// 反序清理:子表常规删除;两张不可变表逐一 DISABLE → 删 → ENABLE(嵌套 finally); // 反序清理:子表常规删除;两张不可变表逐一 DISABLE→删→ENABLE(嵌套 finally 保证触发器恢复);
// audit_logs 按 fixture 唯一 command_id 删(含组织级 NULL site 行);最后删 site。 // audit_logs 按 fixture 唯一 command_id 删(含组织级 NULL site 行);最后删 site。
async function cleanupJobsFixture(db: Db, f: Fixture) { async function cleanupJobsFixture(db: Db, f: Fixture) {
await db.execute(`delete from validation_issues where validation_run_id = '${f.validationRunId}'`) await db.execute(`delete from validation_issues where validation_run_id = '${f.validationRunId}'`)
await db.execute(`delete from validation_runs where site_id = '${f.siteId}'`) await db.execute(`delete from validation_runs where site_id = '${f.siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id = '${f.buildJobId}'`) await db.execute(`delete from build_artifacts where build_job_id = '${f.buildJobId}'`)
await db.execute(`alter table build_input_snapshots disable trigger build_input_snapshots_immutable`) await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${f.siteId}'`)
try { await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `command_id = '${f.auditCommandId}'`)
await db.execute(`delete from build_input_snapshots where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table build_input_snapshots enable trigger build_input_snapshots_immutable`)
}
await db.execute(`alter table audit_logs disable trigger audit_logs_immutable`)
try {
await db.execute(`delete from audit_logs where command_id = '${f.auditCommandId}'`)
} finally {
await db.execute(`alter table audit_logs enable trigger audit_logs_immutable`)
}
await db.execute(`delete from build_jobs where site_id = '${f.siteId}'`) await db.execute(`delete from build_jobs where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`) await db.execute(`delete from sites where id = '${f.siteId}'`)
} }
async function withJobsFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withJobsFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupJobsFixture(db) const f = await setupJobsFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupJobsFixture(db, f) await cleanupJobsFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言触发器/约束/枚举身份时必须看 cause。
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}`)
} }
// 不可变快照探测插入:换新 id 但复用 fixture 的 build_job_id(唯一键探测用)。 // 不可变快照探测插入:换新 id 但复用 fixture 的 build_job_id(唯一键探测用)。
+17 -35
View File
@@ -2,13 +2,12 @@
// (各带"换列值放行"对照组——同站不同源 URL / 不同公开路径都是合法的第二次入库)+ // (各带"换列值放行"对照组——同站不同源 URL / 不同公开路径都是合法的第二次入库)+
// media_usages 复合主键 (media_asset_id, content_revision_id, field_path)(换 field_path 放行—— // media_usages 复合主键 (media_asset_id, content_revision_id, field_path)(换 field_path 放行——
// 同媒体同修订不同字段位引用合法)+ status enum 四值可写往返 + source_id 可空(WP 站外媒体无源 ID)。 // 同媒体同修订不同字段位引用合法)+ status enum 四值可写往返 + source_id 可空(WP 站外媒体无源 ID)。
// 生命周期纪律:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走 // Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 // 生命周期纪律不变:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走
// 清场顺序:usages → assets → connections →(DISABLE 删 revisions)→ entries → site。 // DISABLE→删→ENABLE 封装。清场顺序:usages → assets → connections →(删 revisions)→ entries → site。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { type Fixture = {
siteId: string siteId: string
connectionId: string connectionId: string
@@ -23,10 +22,9 @@ type Fixture = {
// 建链(FK 依赖顺序):site → source_connection(asset 的来源)+ content_entry → content_revision // 建链(FK 依赖顺序):site → source_connection(asset 的来源)+ content_entry → content_revision
// (usage 的引用目标)→ media_asset(ready 态,供双唯一探测与 enum 往返)。 // (usage 的引用目标)→ media_asset(ready 态,供双唯一探测与 enum 往返)。
async function setupMediaFixture(db: Db): Promise<Fixture> { async function setupMediaFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'media')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) const rand = site.rand
values (gen_random_uuid(), 'media-${rand}', 'MediaFixture', 'en', 'https://media.example') returning id`)) as any const siteId = site.siteId
const siteId = site[0].id
const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration) const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration)
values (gen_random_uuid(), '${siteId}', 'wxr', 'media-conn-${rand}', '{}') returning id`)) as any values (gen_random_uuid(), '${siteId}', 'wxr', 'media-conn-${rand}', '{}') returning id`)) as any
const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status) const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status)
@@ -50,41 +48,25 @@ async function setupMediaFixture(db: Db): Promise<Fixture> {
} }
// 反序清理:media_usages → media_assets → source_connections → // 反序清理:media_usages → media_assets → source_connections →
// content_revisions(不可变:DISABLE → 删 → ENABLE 嵌套 finally)→ content_entries → site。 // content_revisions(不可变:DISABLE→删→ENABLE)→ content_entries → site。
async function cleanupMediaFixture(db: Db, f: Fixture) { async function cleanupMediaFixture(db: Db, f: Fixture) {
await db.execute(`delete from media_usages where site_id = '${f.siteId}'`) await db.execute(`delete from media_usages where site_id = '${f.siteId}'`)
await db.execute(`delete from media_assets where site_id = '${f.siteId}'`) await db.execute(`delete from media_assets where site_id = '${f.siteId}'`)
await db.execute(`delete from source_connections where site_id = '${f.siteId}'`) await db.execute(`delete from source_connections where site_id = '${f.siteId}'`)
await db.execute(`alter table content_revisions disable trigger content_revisions_immutable`) await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${f.siteId}'`)
try {
await db.execute(`delete from content_revisions where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table content_revisions enable trigger content_revisions_immutable`)
}
await db.execute(`delete from content_entries where site_id = '${f.siteId}'`) await db.execute(`delete from content_entries where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`) await db.execute(`delete from sites where id = '${f.siteId}'`)
} }
async function withMediaFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withMediaFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupMediaFixture(db) const f = await setupMediaFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupMediaFixture(db, f) await cleanupMediaFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言约束/枚举身份时必须看 cause。
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}`)
} }
// 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。 // 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。
+13 -26
View File
@@ -7,18 +7,16 @@
// 3. query_key 参与唯一:同 exact key 下 query_key='p' 与 NULL 共存;同 'p' 第二条拒绝; // 3. query_key 参与唯一:同 exact key 下 query_key='p' 与 NULL 共存;同 'p' 第二条拒绝;
// 4. route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)往返 + 第七值拒绝; // 4. route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)往返 + 第七值拒绝;
// 5. query_policy enum 四值(ignore/exact/preserve/dynamic)往返 + 第五值拒绝。 // 5. query_policy enum 四值(ignore/exact/preserve/dynamic)往返 + 第五值拒绝。
// 生命周期纪律:随机后缀建 site + finally 反序清理(route_registry → sites);本表无不可变触发器。 // Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀建 site + finally 反序清理(route_registry → sites);本表无不可变触发器。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { siteId: string; rand: string } type Fixture = { siteId: string; rand: string }
async function setupRoutesFixture(db: Db): Promise<Fixture> { async function setupRoutesFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'routes')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) return { siteId: site.siteId, rand: site.rand }
values (gen_random_uuid(), 'routes-${rand}', 'RoutesFixture', 'en', 'https://routes.example') returning id`)) as any
return { siteId: site[0].id, rand }
} }
// 反序清理:route_registry(无不可变触发器,直接删)→ sites(级联挡在 site 前,先删子表)。 // 反序清理:route_registry(无不可变触发器,直接删)→ sites(级联挡在 site 前,先删子表)。
@@ -28,25 +26,14 @@ async function cleanupRoutesFixture(db: Db, f: Fixture) {
} }
async function withRoutesFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withRoutesFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupRoutesFixture(db) const f = await setupRoutesFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupRoutesFixture(db, f) await cleanupRoutesFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言约束/枚举身份时必须看 cause。
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}`)
} }
// 探测插入:除显式指定的列外全部换新值(随机 suffix 的 id/path/collision_key),保证被测 // 探测插入:除显式指定的列外全部换新值(随机 suffix 的 id/path/collision_key),保证被测
+17 -19
View File
@@ -1,26 +1,24 @@
// 站点域集成测试:约束在真 PG 上生效(唯一键、复合唯一键)。 // 站点域集成测试:约束在真 PG 上生效(唯一键、复合唯一键)。
// 简易内联版——Task 13 收敛为公共 helper;本任务只需连库即可(getDb 从 apps/api 源码导入)。 // Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb);
// 生命周期纪律:随机 key 插入 + finally 清理,可重复运行不残留脏数据。 // 生命周期纪律不变:随机 key 插入 + finally 清理,可重复运行不残留脏数据。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withTestDb } from './helpers.js'
describe('站点域约束', () => { describe('站点域约束', () => {
it('sites.key 唯一;site_config_revisions (site_id,version) 唯一', async () => { it('sites.key 唯一;site_config_revisions (site_id,version) 唯一', async () => {
const db = getDb() await withTestDb(async (db) => {
const key = `fixture-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'fixture')
const r = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) try {
values (gen_random_uuid(), '${key}', 'Fixture', 'en', 'https://fixture.example') returning id`)) as any await expect(db.execute(`insert into sites (id, key, name, default_locale, production_origin)
const siteId = r[0].id values (gen_random_uuid(), '${site.key}', 'Dup', 'en', 'https://x.example')`)).rejects.toThrow()
try { await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum)
await expect(db.execute(`insert into sites (id, key, name, default_locale, production_origin) values (gen_random_uuid(), '${site.siteId}', 1, '{"a":1}', 'c1')`)
values (gen_random_uuid(), '${key}', 'Dup', 'en', 'https://x.example')`)).rejects.toThrow() await expect(db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum)
await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) values (gen_random_uuid(), '${site.siteId}', 1, '{"a":2}', 'c2')`)).rejects.toThrow()
values (gen_random_uuid(), '${siteId}', 1, '{"a":1}', 'c1')`) } finally {
await expect(db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) await db.execute(`delete from site_config_revisions where site_id = '${site.siteId}'`)
values (gen_random_uuid(), '${siteId}', 1, '{"a":2}', 'c2')`)).rejects.toThrow() await site.cleanup()
} finally { }
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`) })
await db.execute(`delete from sites where id = '${siteId}'`)
}
}) })
}) })
+17 -34
View File
@@ -1,19 +1,18 @@
// 来源域集成测试:快照不可变触发器(UPDATE/DELETE 拒绝)+ 三处唯一约束在真 PG 上生效。 // 来源域集成测试:快照不可变触发器(UPDATE/DELETE 拒绝)+ 三处唯一约束在真 PG 上生效。
// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。 // Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清理时先 DISABLE 该触发器再删行后 ENABLE // 生命周期纪律不变:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。
// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清场走 withImmutableCleanup
// (不可变表的生产语义是永不删;测试清场是唯一例外,走显式 DDL 开关且删除限定本站点行)。 // (不可变表的生产语义是永不删;测试清场是唯一例外,走显式 DDL 开关且删除限定本站点行)。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { siteId: string; connectionId: string; snapshotId: string; sha256: string; idempotencyKey: string } type Fixture = { siteId: string; connectionId: string; snapshotId: string; sha256: string; idempotencyKey: string }
// 建链(FK 依赖顺序):site → source_connection → source_snapshot → import_job // 建链(FK 依赖顺序):site → source_connection → source_snapshot → import_job
async function setupSourceFixture(db: Db): Promise<Fixture> { async function setupSourceFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'src')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) const rand = site.rand
values (gen_random_uuid(), 'src-${rand}', 'SrcFixture', 'en', 'https://src.example') returning id`)) as any const siteId = site.siteId
const siteId = site[0].id
const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration) const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration)
values (gen_random_uuid(), '${siteId}', 'wxr', 'conn-${rand}', '{}') returning id`)) as any values (gen_random_uuid(), '${siteId}', 'wxr', 'conn-${rand}', '{}') returning id`)) as any
const connectionId = conn[0].id const connectionId = conn[0].id
@@ -24,40 +23,24 @@ async function setupSourceFixture(db: Db): Promise<Fixture> {
return { siteId, connectionId, snapshotId: snap[0].id, sha256: snap[0].sha256, idempotencyKey: job[0].idempotency_key } return { siteId, connectionId, snapshotId: snap[0].id, sha256: snap[0].sha256, idempotencyKey: job[0].idempotency_key }
} }
// 反序清理:子表常规删除;快照表临时 DISABLE 不可变触发器;最后删 site。 // 反序清理:子表常规删除;快照表不可变,清场走 DISABLE→删→ENABLE 封装;最后删 site。
async function cleanupSourceFixture(db: Db, f: Fixture) { async function cleanupSourceFixture(db: Db, f: Fixture) {
await db.execute(`delete from source_records where site_id = '${f.siteId}'`) await db.execute(`delete from source_records where site_id = '${f.siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`) await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`)
await db.execute(`alter table source_snapshots disable trigger source_snapshots_immutable`) await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${f.siteId}'`)
try {
await db.execute(`delete from source_snapshots where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table source_snapshots enable trigger source_snapshots_immutable`)
}
await db.execute(`delete from source_connections where site_id = '${f.siteId}'`) await db.execute(`delete from source_connections where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`) await db.execute(`delete from sites where id = '${f.siteId}'`)
} }
async function withSourceFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withSourceFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupSourceFixture(db) const f = await setupSourceFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupSourceFixture(db, f) await cleanupSourceFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言触发器/约束身份时必须看 cause。
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}`)
} }
describe('来源域约束', () => { describe('来源域约束', () => {
+14 -26
View File
@@ -1,12 +1,12 @@
// 分类域集成测试:taxonomies 三元组唯一(换 object_type 放行对照——同 key 服务不同对象类型是合法的, // 分类域集成测试:taxonomies 三元组唯一(换 object_type 放行对照——同 key 服务不同对象类型是合法的,
// 如 post 与 product 各挂一套 category)+ terms 来源身份唯一 + content_terms 复合主键 + // 如 post 与 product 各挂一套 category)+ terms 来源身份唯一 + content_terms 复合主键 +
// terms.parent_term_id 自引用 FK + is_primary 布尔列(Yoast 主分类)。 // terms.parent_term_id 自引用 FK + is_primary 布尔列(Yoast 主分类)。
// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据(本域无不可变表,常规删除即可; // Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀插入 + finally 反序清理(本域无不可变表,常规删除即可;
// content_terms 依赖 content_entries,fixture 建链照 Task 8 模式只建到 entry 即可)。 // content_terms 依赖 content_entries,fixture 建链照 Task 8 模式只建到 entry 即可)。
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js' import { createSiteFixture, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Db = ReturnType<typeof getDb>
type Fixture = { type Fixture = {
siteId: string siteId: string
entryId: string entryId: string
@@ -17,10 +17,9 @@ type Fixture = {
// 建链(FK 依赖顺序):site → content_entry → taxonomy → term(默认无 parent,层级在各测试内自建)。 // 建链(FK 依赖顺序):site → content_entry → taxonomy → term(默认无 parent,层级在各测试内自建)。
async function setupTaxonomyFixture(db: Db): Promise<Fixture> { async function setupTaxonomyFixture(db: Db): Promise<Fixture> {
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const site = await createSiteFixture(db, 'tax')
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) const rand = site.rand
values (gen_random_uuid(), 'tax-${rand}', 'TaxFixture', 'en', 'https://tax.example') returning id`)) as any const siteId = site.siteId
const siteId = site[0].id
const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status) const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status)
values (gen_random_uuid(), '${siteId}', 'post', 'active') returning id`)) as any values (gen_random_uuid(), '${siteId}', 'post', 'active') returning id`)) as any
const tax = (await db.execute(`insert into taxonomies (id, site_id, key, object_type, public_base_path) const tax = (await db.execute(`insert into taxonomies (id, site_id, key, object_type, public_base_path)
@@ -47,25 +46,14 @@ async function cleanupTaxonomyFixture(db: Db, f: Fixture) {
} }
async function withTaxonomyFixture(fn: (db: Db, f: Fixture) => Promise<void>) { async function withTaxonomyFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
const db = getDb() await withTestDb(async (db) => {
const f = await setupTaxonomyFixture(db) const f = await setupTaxonomyFixture(db)
try { try {
await fn(db, f) await fn(db, f)
} finally { } finally {
await cleanupTaxonomyFixture(db, f) await cleanupTaxonomyFixture(db, f)
await (db as any).$client.end() }
} })
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言约束身份时必须看 cause。
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}`)
} }
describe('分类域约束', () => { describe('分类域约束', () => {
+42
View File
@@ -0,0 +1,42 @@
// vitest globalSetup(Task 13):每次测试运行用完好的空临时库,结束即删——
// 彻底消除"测试碰开发库"与"清场残留"两类问题。
// 机制:用开发库凭据连维护库(postgres)CREATE DATABASE sf_test_<ts> →
// 用 drizzle-orm 程序化迁移器对临时库跑 apps/api/drizzle 全量迁移 →
// 设 process.env.DATABASE_URL_TEST(getDb() 的 DATABASE_URL_TEST ?? DATABASE_URL
// 回退链让所有测试自动连临时库;worker 由主进程在 setup 之后派生,env 继承生效)。
// 导出形式注意:vitest 的 globalSetup 加载器在有 default 导出时不捕获具名 teardown
// (loadGlobalSetupFile 的 if/default 分支只取 default 作 setup),故这里必须
// 用具名 setup + teardown 双导出、不写 default——teardown 才会被注册并在收尾执行。
// teardown DROP DATABASE 用 WITH (FORCE) 先断开残余业务连接再删;setup 中途失败也自清。
import { requireDatabaseUrl } from '../../apps/api/src/db/env.js'
import { createDatabase, dropDatabase, migrateDatabase } from '../../apps/api/src/db/migrate.js'
let created: { adminUrl: string; name: string } | undefined
export async function setup(): Promise<void> {
// 只认 DATABASE_URL(开发库凭据)——即便环境里残留 DATABASE_URL_TEST 也不受影响
const devUrl = requireDatabaseUrl({ allowTestUrl: false })
const adminUrl = new URL(devUrl)
adminUrl.pathname = '/postgres' // CREATE/DROP DATABASE 须在维护库会话里执行
const name = `sf_test_${Date.now()}`
const testUrl = new URL(devUrl)
testUrl.pathname = `/${name}`
await createDatabase(adminUrl.toString(), name)
try {
await migrateDatabase(testUrl.toString())
} catch (e) {
// 迁移半途而废:立即清掉本次临时库再抛出,失败运行也不留残留
await dropDatabase(adminUrl.toString(), name)
throw e
}
process.env.DATABASE_URL_TEST = testUrl.toString()
created = { adminUrl: adminUrl.toString(), name }
}
export async function teardown(): Promise<void> {
if (!created) return
const { adminUrl, name } = created
created = undefined
await dropDatabase(adminUrl, name)
}
+69
View File
@@ -0,0 +1,69 @@
// 集成测试公共夹具(Task 13 收敛):Task 6–12 七个域测试文件的内联重复件抽此共享。
// 收敛纪律:只抽"每个文件逐字重复"的实现——随机后缀、建 site、不可变表清场开关、
// 拒绝断言解包、连接生命周期;各文件的断言、对照组、清场范围与顺序保持不变。
import { getDb } from '../../apps/api/src/db/client.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()
}
}
+3
View File
@@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['tests/**/*.test.ts', 'packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], include: ['tests/**/*.test.ts', 'packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'],
// 集成测试库供给(Task 13):setup 建 sf_test_<ts> 临时库并全量迁移,
// 设 DATABASE_URL_TEST 让所有 getDb() 自动指过去;teardown 负责 DROP。
globalSetup: ['tests/integration/global-setup.ts'],
testTimeout: 30000, testTimeout: 30000,
// 集成测试共库且清场会做全局态变更(不可变表 DISABLE/ENABLE TRIGGER 是库级 DDL, // 集成测试共库且清场会做全局态变更(不可变表 DISABLE/ENABLE TRIGGER 是库级 DDL,
// 对所有会话可见):文件级并行时 A 文件清场窗口会与 B 文件的不可变断言竞态(实测 // 对所有会话可见):文件级并行时 A 文件清场窗口会与 B 文件的不可变断言竞态(实测