Files
cms-website/tests/integration/db.content.test.ts
T
浅念 f1c181de02 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>
2026-10-20 20:43:13 +08:00

106 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 内容域集成测试:修订不可变触发器(UPDATE/DELETE 拒绝)+ entry_locales 三元组唯一 +
// 双指针语义(发布=只动 entry_locales 指针,不碰修订行)+ source_records.content_entry_id 回填 FK。
// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots)
// 清场走 DISABLE→删→ENABLE 封装,嵌套 finally 保证触发器必然恢复。
import { describe, it, expect } from 'vitest'
import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Fixture = {
siteId: string
entryId: string
localeId: string
revisionId: string
connectionId: string
snapshotId: string
importJobId: string
}
// 建链(FK 依赖顺序):site → content_entry → content_revision → entry_locale(current 指针指向该修订,published=NULL);
// 另建 source 链(connection → snapshot → job)供 source_records FK 探针使用。
async function setupContentFixture(db: Db): Promise<Fixture> {
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
const rev = (await db.execute(`insert into content_revisions (id, site_id, content_entry_id, locale, schema_version, data, checksum, source_kind, created_by)
values (gen_random_uuid(), '${siteId}', '${entryId}', 'en', 1, '{"title":"v1"}', 'ck-${rand}', 'manual', 'tester') returning id`)) as any
const revisionId = rev[0].id
const locale = (await db.execute(`insert into entry_locales (id, site_id, content_entry_id, locale, slug, current_revision_id, published_revision_id)
values (gen_random_uuid(), '${siteId}', '${entryId}', 'en', 'slug-${rand}', '${revisionId}', null) returning id`)) as any
const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration)
values (gen_random_uuid(), '${siteId}', 'wxr', 'content-conn-${rand}', '{}') returning id`)) as any
const connectionId = conn[0].id
const snap = (await db.execute(`insert into source_snapshots (id, site_id, source_connection_id, kind, storage_path, sha256, byte_size, adapter_version, captured_at, metadata)
values (gen_random_uuid(), '${siteId}', '${connectionId}', 'wxr', 'wxr://${rand}.xml', 'sha256-${rand}', 123, 'wxr@1', now(), '{}') returning id`)) as any
const job = (await db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status, mapping_version, idempotency_key, attempt)
values (gen_random_uuid(), '${siteId}', '${snap[0].id}', 'queued', 'mapping@1', 'idem-content-${rand}', 0) returning id`)) as any
return { siteId, entryId, localeId: locale[0].id, revisionId, connectionId, snapshotId: snap[0].id, importJobId: job[0].id }
}
// 反序清理。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 withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${f.siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${f.siteId}'`)
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<void>) {
await withTestDb(async (db) => {
const f = await setupContentFixture(db)
try {
await fn(db, f)
} finally {
await cleanupContentFixture(db, f)
}
})
}
describe('内容域约束(修订不可变 + 双指针)', () => {
it('content_revisions 不可变:UPDATE 与 DELETE 均被触发器拒绝', async () => {
await withContentFixture(async (db, f) => {
expect(await rejectionMessage(db, `update content_revisions set checksum = 'tampered' where id = '${f.revisionId}'`))
.toMatch(/immutable table content_revisions/)
expect(await rejectionMessage(db, `delete from content_revisions where id = '${f.revisionId}'`))
.toMatch(/immutable table content_revisions/)
})
})
it('entry_locales UNIQUE(site_id, content_entry_id, locale):同三元组重复插入拒绝,换 locale 放行', async () => {
await withContentFixture(async (db, f) => {
await expect(db.execute(`insert into entry_locales (id, site_id, content_entry_id, locale, slug)
values (gen_random_uuid(), '${f.siteId}', '${f.entryId}', 'en', 'dup-slug')`))
.rejects.toThrow()
// 对照组:同 entry 换 locale → 允许(约束打在 site+entry+locale 三元组上,非 entry 级唯一)
await db.execute(`insert into entry_locales (id, site_id, content_entry_id, locale, slug)
values (gen_random_uuid(), '${f.siteId}', '${f.entryId}', 'zh', 'slug-zh')`)
})
})
it('双指针语义:发布=只 UPDATE entry_locales.published_revision_id,不碰修订行(ADR-010)', async () => {
await withContentFixture(async (db, f) => {
// 初始:current=X、published=NULL(未发布)。发布动作 = 指针 UPDATE——库层必须放行
// (修订行自身不可变,但指针是 entry_locales 的可变列;V2 设计的库级表达)
await db.execute(`update entry_locales set published_revision_id = '${f.revisionId}' where id = '${f.localeId}'`)
const after = (await db.execute(`select current_revision_id, published_revision_id from entry_locales where id = '${f.localeId}'`)) as any
expect(after[0].current_revision_id).toBe(f.revisionId)
expect(after[0].published_revision_id).toBe(f.revisionId)
})
})
it('source_records.content_entry_id 回填 FK:指向不存在 entry 的插入被拒绝', async () => {
await withContentFixture(async (db, f) => {
expect(await rejectionMessage(db, `insert into source_records (id, site_id, source_connection_id, source_snapshot_id, import_job_id, source_type, source_id, locale, raw_location, parse_status, content_entry_id)
values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', '${f.snapshotId}', '${f.importJobId}', 'post', 'src-1', 'en', 'raw://1', 'parsed', gen_random_uuid())`))
.toMatch(/source_records_content_entry_id_fkey/)
})
})
})