Files
cms-website/tests/integration/db.content.test.ts
T

128 lines
7.7 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。
// 生命周期纪律:随机后缀插入 + finally 反序清理;不可变表(content_revisions / source_snapshots)
// 清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。
import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js'
type Db = ReturnType<typeof getDb>
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 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 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(嵌套 finally)。
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 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 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>) {
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<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('内容域约束(修订不可变 + 双指针)', () => {
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/)
})
})
})