Files
cms-website/tests/integration/db.source.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

90 lines
6.2 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 拒绝)+ 三处唯一约束在真 PG 上生效。
// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据。
// 注意:source_snapshots 不可变触发器连 DELETE 也拒——清场走 withImmutableCleanup
// (不可变表的生产语义是永不删;测试清场是唯一例外,走显式 DDL 开关且删除限定本站点行)。
import { describe, it, expect } from 'vitest'
import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
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<Fixture> {
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
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, sha256`)) 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-${rand}', 0) returning idempotency_key`)) as any
return { siteId, connectionId, snapshotId: snap[0].id, sha256: snap[0].sha256, idempotencyKey: job[0].idempotency_key }
}
// 反序清理:子表常规删除;快照表不可变,清场走 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 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<void>) {
await withTestDb(async (db) => {
const f = await setupSourceFixture(db)
try {
await fn(db, f)
} finally {
await cleanupSourceFixture(db, f)
}
})
}
describe('来源域约束', () => {
it('source_snapshots 不可变:UPDATE 与 DELETE 均被触发器拒绝', async () => {
await withSourceFixture(async (db, f) => {
expect(await rejectionMessage(db, `update source_snapshots set sha256 = 'tampered' where id = '${f.snapshotId}'`))
.toMatch(/immutable table source_snapshots/)
expect(await rejectionMessage(db, `delete from source_snapshots where id = '${f.snapshotId}'`))
.toMatch(/immutable table source_snapshots/)
})
})
it('source_snapshots UNIQUE(site_id, kind, sha256):同三元组重复插入拒绝,不同 sha256 放行', async () => {
await withSourceFixture(async (db, f) => {
await expect(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(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://dup.xml', '${f.sha256}', 456, 'wxr@1', now(), '{}')`))
.rejects.toThrow()
// 对照组:同 site+kind 但 sha256 不同 → 允许(证明约束打在三元组上,由 finally 按 site 清理)
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(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://other.xml', 'sha256-other-${f.sha256}', 789, 'wxr@1', now(), '{}')`)
})
})
it('source_records 稳定身份键 UNIQUE(site_id, source_connection_id, source_type, source_id, locale, source_snapshot_id):同键重复拒绝,换 snapshot 放行', async () => {
await withSourceFixture(async (db, f) => {
const insertRecord = (snapshotId: string, rawLocation: string) =>
db.execute(`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)
values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', '${snapshotId}', (select id from import_jobs where site_id = '${f.siteId}' limit 1), 'post', 'source-1', 'en', '${rawLocation}', 'parsed')`)
await insertRecord(f.snapshotId, 'raw://1')
// 同稳定身份键 + 同 snapshot:第二次插入拒绝
await expect(insertRecord(f.snapshotId, 'raw://1-dup')).rejects.toThrow()
// 对照组:同稳定身份键、不同 snapshot(跨快照再导入同一来源项)→ 允许,由 finally 按 site 清理
const snap2 = (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(), '${f.siteId}', '${f.connectionId}', 'wxr', 'wxr://v2.xml', 'sha256-v2-${f.sha256}', 999, 'wxr@1', now(), '{}') returning id`)) as any
await insertRecord(snap2[0].id, 'raw://1-v2')
})
})
it('import_jobs.idempotency_key 唯一:重复拒绝', async () => {
await withSourceFixture(async (db, f) => {
await expect(db.execute(`insert into import_jobs (id, site_id, source_snapshot_id, status, mapping_version, idempotency_key, attempt)
values (gen_random_uuid(), '${f.siteId}', '${f.snapshotId}', 'queued', 'mapping@1', '${f.idempotencyKey}', 0)`))
.rejects.toThrow()
})
})
})