diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..f33853d --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -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 { + 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 { + 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 { + assertSafeName(name) + const sql = postgres(adminConnectionString, { max: 1 }) + try { + await sql.unsafe(`drop database if exists "${name}" with (force)`) + } finally { + await sql.end() + } +} diff --git a/scripts/migrate-test.sh b/scripts/migrate-test.sh new file mode 100644 index 0000000..91d6583 --- /dev/null +++ b/scripts/migrate-test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 一键空库迁移验收(Task 13):确保 docker PG 在跑 → 跑全量测试。 +# vitest globalSetup 每次运行会新建 sf_test_ 临时库、跑全量迁移、结束 DROP—— +# 开发库(siteforge)零写入,无需预迁移。 +# --wait:等 PG 容器 healthcheck 就绪再继续,避免冷启动期连接被拒。 +set -euo pipefail +cd "$(dirname "$0")/.." + +docker compose -f infra/docker-compose.yml up -d --wait +pnpm test diff --git a/tests/integration/db.content.test.ts b/tests/integration/db.content.test.ts index 5981139..ee5f65a 100644 --- a/tests/integration/db.content.test.ts +++ b/tests/integration/db.content.test.ts @@ -1,11 +1,11 @@ // 内容域集成测试:修订不可变触发器(UPDATE/DELETE 拒绝)+ entry_locales 三元组唯一 + // 双指针语义(发布=只动 entry_locales 指针,不碰修订行)+ source_records.content_entry_id 回填 FK。 -// 生命周期纪律:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots) -// 清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 +// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage); +// 生命周期纪律不变:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots) +// 清场走 DISABLE→删→ENABLE 封装,嵌套 finally 保证触发器必然恢复。 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 type Fixture = { siteId: string entryId: string @@ -19,10 +19,9 @@ type Fixture = { // 建链(FK 依赖顺序):site → content_entry → content_revision → entry_locale(current 指针指向该修订,published=NULL); // 另建 source 链(connection → snapshot → job)供 source_records FK 探针使用。 async function setupContentFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'content-${rand}', 'ContentFixture', 'en', 'https://content.example') returning id`)) as any - const siteId = site[0].id + const site = await createSiteFixture(db, 'content') + const rand = site.rand + const siteId = site.siteId 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 const entryId = entry[0].id @@ -41,48 +40,27 @@ async function setupContentFixture(db: Db): Promise { 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) { 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(`alter table content_revisions disable trigger content_revisions_immutable`) - 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 withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `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`) - 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 withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `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 sites where id = '${f.siteId}'`) } async function withContentFixture(fn: (db: Db, f: Fixture) => Promise) { - const db = getDb() - const f = await setupContentFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupContentFixture(db) + try { + await fn(db, f) + } finally { + await cleanupContentFixture(db, f) + } + }) } describe('内容域约束(修订不可变 + 双指针)', () => { diff --git a/tests/integration/db.full-migrate.test.ts b/tests/integration/db.full-migrate.test.ts new file mode 100644 index 0000000..907415d --- /dev/null +++ b/tests/integration/db.full-migrate.test.ts @@ -0,0 +1,47 @@ +// 空库全量迁移验收(Task 13 / 阶段 1 收官):globalSetup 对 sf_test_ 临时库跑 +// 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+$/) + }) +}) diff --git a/tests/integration/db.jobs.test.ts b/tests/integration/db.jobs.test.ts index 728b0f8..0b9d6b6 100644 --- a/tests/integration/db.jobs.test.ts +++ b/tests/integration/db.jobs.test.ts @@ -1,13 +1,13 @@ // 构建/验收/审计域集成测试(0006 迁移):build_jobs 幂等键唯一 + 状态 enum 八值、 // build_input_snapshots 不可变触发器 + build_job_id UNIQUE、build_artifacts 三元组唯一、 // audit_logs 追加式(UPDATE/DELETE 拒绝)、validation_issues.severity 可写。 -// 生命周期纪律:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots / -// audit_logs)清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 +// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage); +// 生命周期纪律不变:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots / +// audit_logs)清场走 DISABLE→删→ENABLE 封装。 // audit_logs 组织级行(site_id NULL,spec §5.8)无 site 可挂——清场按 fixture 唯一 command_id 圈定。 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 type Fixture = { siteId: string buildJobId: string @@ -20,10 +20,9 @@ type Fixture = { // 建链(FK 依赖顺序):site → build_job → build_input_snapshot;validation_run 挂 site+job; // audit_logs 挂 site(站点级行;组织级 NULL 行在测试体内插,由 command_id 清场兜底)。 async function setupJobsFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'jobs-${rand}', 'JobsFixture', 'en', 'https://jobs.example') returning id`)) as any - const siteId = site[0].id + const site = await createSiteFixture(db, 'jobs') + const rand = site.rand + const siteId = site.siteId 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 const buildJobId = job[0].id @@ -45,48 +44,27 @@ async function setupJobsFixture(db: Db): Promise { } } -// 反序清理:子表常规删除;两张不可变表逐一 DISABLE → 删 → ENABLE(嵌套 finally); +// 反序清理:子表常规删除;两张不可变表逐一 DISABLE→删→ENABLE(嵌套 finally 保证触发器恢复); // audit_logs 按 fixture 唯一 command_id 删(含组织级 NULL site 行);最后删 site。 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_runs where site_id = '${f.siteId}'`) 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`) - try { - 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 withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${f.siteId}'`) + await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `command_id = '${f.auditCommandId}'`) await db.execute(`delete from build_jobs where site_id = '${f.siteId}'`) await db.execute(`delete from sites where id = '${f.siteId}'`) } async function withJobsFixture(fn: (db: Db, f: Fixture) => Promise) { - const db = getDb() - const f = await setupJobsFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupJobsFixture(db) + try { + await fn(db, f) + } finally { + await cleanupJobsFixture(db, f) + } + }) } // 不可变快照探测插入:换新 id 但复用 fixture 的 build_job_id(唯一键探测用)。 diff --git a/tests/integration/db.media.test.ts b/tests/integration/db.media.test.ts index 83de60c..5bc89ee 100644 --- a/tests/integration/db.media.test.ts +++ b/tests/integration/db.media.test.ts @@ -2,13 +2,12 @@ // (各带"换列值放行"对照组——同站不同源 URL / 不同公开路径都是合法的第二次入库)+ // media_usages 复合主键 (media_asset_id, content_revision_id, field_path)(换 field_path 放行—— // 同媒体同修订不同字段位引用合法)+ status enum 四值可写往返 + source_id 可空(WP 站外媒体无源 ID)。 -// 生命周期纪律:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走 -// DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。 -// 清场顺序:usages → assets → connections →(DISABLE 删 revisions)→ entries → site。 +// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage); +// 生命周期纪律不变:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走 +// DISABLE→删→ENABLE 封装。清场顺序:usages → assets → connections →(删 revisions)→ entries → site。 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 type Fixture = { siteId: string connectionId: string @@ -23,10 +22,9 @@ type Fixture = { // 建链(FK 依赖顺序):site → source_connection(asset 的来源)+ content_entry → content_revision // (usage 的引用目标)→ media_asset(ready 态,供双唯一探测与 enum 往返)。 async function setupMediaFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'media-${rand}', 'MediaFixture', 'en', 'https://media.example') returning id`)) as any - const siteId = site[0].id + const site = await createSiteFixture(db, 'media') + const rand = site.rand + const siteId = site.siteId 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 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 { } // 反序清理: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) { 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 source_connections where site_id = '${f.siteId}'`) - await db.execute(`alter table content_revisions disable trigger content_revisions_immutable`) - 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 withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `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}'`) } async function withMediaFixture(fn: (db: Db, f: Fixture) => Promise) { - const db = getDb() - const f = await setupMediaFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupMediaFixture(db) + try { + await fn(db, f) + } finally { + await cleanupMediaFixture(db, f) + } + }) } // 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。 diff --git a/tests/integration/db.routes.test.ts b/tests/integration/db.routes.test.ts index 42ccbe2..c40d368 100644 --- a/tests/integration/db.routes.test.ts +++ b/tests/integration/db.routes.test.ts @@ -7,18 +7,16 @@ // 3. query_key 参与唯一:同 exact key 下 query_key='p' 与 NULL 共存;同 'p' 第二条拒绝; // 4. route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)往返 + 第七值拒绝; // 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 { getDb } from '../../apps/api/src/db/client.js' +import { createSiteFixture, withTestDb, rejectionMessage, type Db } from './helpers.js' -type Db = ReturnType type Fixture = { siteId: string; rand: string } async function setupRoutesFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'routes-${rand}', 'RoutesFixture', 'en', 'https://routes.example') returning id`)) as any - return { siteId: site[0].id, rand } + const site = await createSiteFixture(db, 'routes') + return { siteId: site.siteId, rand: site.rand } } // 反序清理:route_registry(无不可变触发器,直接删)→ sites(级联挡在 site 前,先删子表)。 @@ -28,25 +26,14 @@ async function cleanupRoutesFixture(db: Db, f: Fixture) { } async function withRoutesFixture(fn: (db: Db, f: Fixture) => Promise) { - const db = getDb() - const f = await setupRoutesFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupRoutesFixture(db) + try { + await fn(db, f) + } finally { + await cleanupRoutesFixture(db, f) + } + }) } // 探测插入:除显式指定的列外全部换新值(随机 suffix 的 id/path/collision_key),保证被测 diff --git a/tests/integration/db.sites.test.ts b/tests/integration/db.sites.test.ts index 599e977..2c7b572 100644 --- a/tests/integration/db.sites.test.ts +++ b/tests/integration/db.sites.test.ts @@ -1,26 +1,24 @@ // 站点域集成测试:约束在真 PG 上生效(唯一键、复合唯一键)。 -// 简易内联版——Task 13 收敛为公共 helper;本任务只需连库即可(getDb 从 apps/api 源码导入)。 -// 生命周期纪律:随机 key 插入 + finally 清理,可重复运行不残留脏数据。 +// Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb); +// 生命周期纪律不变:随机 key 插入 + finally 清理,可重复运行不残留脏数据。 import { describe, it, expect } from 'vitest' -import { getDb } from '../../apps/api/src/db/client.js' +import { createSiteFixture, withTestDb } from './helpers.js' describe('站点域约束', () => { it('sites.key 唯一;site_config_revisions (site_id,version) 唯一', async () => { - const db = getDb() - const key = `fixture-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const r = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), '${key}', 'Fixture', 'en', 'https://fixture.example') returning id`)) as any - const siteId = r[0].id - try { - await expect(db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), '${key}', 'Dup', 'en', 'https://x.example')`)).rejects.toThrow() - await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) - values (gen_random_uuid(), '${siteId}', 1, '{"a":1}', 'c1')`) - await expect(db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) - values (gen_random_uuid(), '${siteId}', 1, '{"a":2}', 'c2')`)).rejects.toThrow() - } finally { - await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`) - await db.execute(`delete from sites where id = '${siteId}'`) - } + await withTestDb(async (db) => { + const site = await createSiteFixture(db, 'fixture') + try { + await expect(db.execute(`insert into sites (id, key, name, default_locale, production_origin) + values (gen_random_uuid(), '${site.key}', 'Dup', 'en', 'https://x.example')`)).rejects.toThrow() + await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) + values (gen_random_uuid(), '${site.siteId}', 1, '{"a":1}', 'c1')`) + await expect(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() + } finally { + await db.execute(`delete from site_config_revisions where site_id = '${site.siteId}'`) + await site.cleanup() + } + }) }) }) diff --git a/tests/integration/db.source.test.ts b/tests/integration/db.source.test.ts index 324eb2f..a76564b 100644 --- a/tests/integration/db.source.test.ts +++ b/tests/integration/db.source.test.ts @@ -1,19 +1,18 @@ // 来源域集成测试:快照不可变触发器(UPDATE/DELETE 拒绝)+ 三处唯一约束在真 PG 上生效。 -// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。 -// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清理时先 DISABLE 该触发器再删行后 ENABLE +// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage); +// 生命周期纪律不变:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。 +// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清场走 withImmutableCleanup // (不可变表的生产语义是永不删;测试清场是唯一例外,走显式 DDL 开关且删除限定本站点行)。 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 type Fixture = { siteId: string; connectionId: string; snapshotId: string; sha256: string; idempotencyKey: string } // 建链(FK 依赖顺序):site → source_connection → source_snapshot → import_job async function setupSourceFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'src-${rand}', 'SrcFixture', 'en', 'https://src.example') returning id`)) as any - const siteId = site[0].id + const site = await createSiteFixture(db, 'src') + const rand = site.rand + const siteId = site.siteId 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 const connectionId = conn[0].id @@ -24,40 +23,24 @@ async function setupSourceFixture(db: Db): Promise { 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) { 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(`alter table source_snapshots disable trigger source_snapshots_immutable`) - 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 withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `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}'`) } async function withSourceFixture(fn: (db: Db, f: Fixture) => Promise) { - const db = getDb() - const f = await setupSourceFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupSourceFixture(db) + try { + await fn(db, f) + } finally { + await cleanupSourceFixture(db, f) + } + }) } describe('来源域约束', () => { diff --git a/tests/integration/db.taxonomy.test.ts b/tests/integration/db.taxonomy.test.ts index 63f16cf..368af60 100644 --- a/tests/integration/db.taxonomy.test.ts +++ b/tests/integration/db.taxonomy.test.ts @@ -1,12 +1,12 @@ // 分类域集成测试:taxonomies 三元组唯一(换 object_type 放行对照——同 key 服务不同对象类型是合法的, // 如 post 与 product 各挂一套 category)+ terms 来源身份唯一 + content_terms 复合主键 + // terms.parent_term_id 自引用 FK + is_primary 布尔列(Yoast 主分类)。 -// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据(本域无不可变表,常规删除即可; +// Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb/rejectionMessage); +// 生命周期纪律不变:随机后缀插入 + finally 反序清理(本域无不可变表,常规删除即可; // content_terms 依赖 content_entries,fixture 建链照 Task 8 模式只建到 entry 即可)。 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 type Fixture = { siteId: string entryId: string @@ -17,10 +17,9 @@ type Fixture = { // 建链(FK 依赖顺序):site → content_entry → taxonomy → term(默认无 parent,层级在各测试内自建)。 async function setupTaxonomyFixture(db: Db): Promise { - const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin) - values (gen_random_uuid(), 'tax-${rand}', 'TaxFixture', 'en', 'https://tax.example') returning id`)) as any - const siteId = site[0].id + const site = await createSiteFixture(db, 'tax') + const rand = site.rand + const siteId = site.siteId 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 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) { - const db = getDb() - const f = await setupTaxonomyFixture(db) - try { - await fn(db, f) - } finally { - 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 { - 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}`) + await withTestDb(async (db) => { + const f = await setupTaxonomyFixture(db) + try { + await fn(db, f) + } finally { + await cleanupTaxonomyFixture(db, f) + } + }) } describe('分类域约束', () => { diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts new file mode 100644 index 0000000..42ae1d1 --- /dev/null +++ b/tests/integration/global-setup.ts @@ -0,0 +1,42 @@ +// vitest globalSetup(Task 13):每次测试运行用完好的空临时库,结束即删—— +// 彻底消除"测试碰开发库"与"清场残留"两类问题。 +// 机制:用开发库凭据连维护库(postgres)CREATE DATABASE sf_test_ → +// 用 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 { + // 只认 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 { + if (!created) return + const { adminUrl, name } = created + created = undefined + await dropDatabase(adminUrl, name) +} diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..72badec --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,69 @@ +// 集成测试公共夹具(Task 13 收敛):Task 6–12 七个域测试文件的内联重复件抽此共享。 +// 收敛纪律:只抽"每个文件逐字重复"的实现——随机后缀、建 site、不可变表清场开关、 +// 拒绝断言解包、连接生命周期;各文件的断言、对照组、清场范围与顺序保持不变。 +import { getDb } from '../../apps/api/src/db/client.js' + +export type Db = ReturnType + +/** 随机后缀:毫秒时间戳 + 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 { + 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 } + +export async function createSiteFixture(db: Db, prefix: string): Promise { + 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 { + 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): Promise { + const db = getDb() + try { + await fn(db) + } finally { + await (db as any).$client.end() + } +} diff --git a/vitest.config.ts b/vitest.config.ts index f0ca422..b5fd956 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['tests/**/*.test.ts', 'packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], + // 集成测试库供给(Task 13):setup 建 sf_test_ 临时库并全量迁移, + // 设 DATABASE_URL_TEST 让所有 getDb() 自动指过去;teardown 负责 DROP。 + globalSetup: ['tests/integration/global-setup.ts'], testTimeout: 30000, // 集成测试共库且清场会做全局态变更(不可变表 DISABLE/ENABLE TRIGGER 是库级 DDL, // 对所有会话可见):文件级并行时 A 文件清场窗口会与 B 文件的不可变断言竞态(实测