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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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('内容域约束(修订不可变 + 双指针)', () => {
|
||||||
|
|||||||
@@ -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+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(唯一键探测用)。
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。
|
// 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。
|
||||||
|
|||||||
@@ -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),保证被测
|
||||||
|
|||||||
@@ -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}'`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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('来源域约束', () => {
|
||||||
|
|||||||
@@ -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('分类域约束', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 文件的不可变断言竞态(实测
|
||||||
|
|||||||
Reference in New Issue
Block a user