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

121 lines
6.9 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.
// 分类域集成测试:taxonomies 三元组唯一(换 object_type 放行对照——同 key 服务不同对象类型是合法的,
// 如 post 与 product 各挂一套 category)+ terms 来源身份唯一 + content_terms 复合主键 +
// terms.parent_term_id 自引用 FK + is_primary 布尔列(Yoast 主分类)。
// Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀插入 + finally 反序清理(本域无不可变表,常规删除即可;
// content_terms 依赖 content_entries,fixture 建链照 Task 8 模式只建到 entry 即可)。
import { describe, it, expect } from 'vitest'
import { createSiteFixture, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Fixture = {
siteId: string
entryId: string
taxonomyId: string
termId: string
termSourceId: string
}
// 建链(FK 依赖顺序):site → content_entry → taxonomy → term(默认无 parent,层级在各测试内自建)。
async function setupTaxonomyFixture(db: Db): Promise<Fixture> {
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)
values (gen_random_uuid(), '${siteId}', 'category', 'post', '/category') returning id`)) as any
const term = (await db.execute(`insert into terms (id, site_id, taxonomy_id, source_id, slug, name, data)
values (gen_random_uuid(), '${siteId}', '${tax[0].id}', 'term-${rand}', 'cat-${rand}', 'Cat ${rand}', '{}') returning id, source_id`)) as any
return {
siteId,
entryId: entry[0].id,
taxonomyId: tax[0].id,
termId: term[0].id,
termSourceId: term[0].source_id,
}
}
// 反序清理:content_terms → terms(自引用 FK 为 NO ACTION,同站全量单条 DELETE 在语句末检查时已无悬挂引用)→
// taxonomies → content_entries → site。
async function cleanupTaxonomyFixture(db: Db, f: Fixture) {
await db.execute(`delete from content_terms where site_id = '${f.siteId}'`)
await db.execute(`delete from terms where site_id = '${f.siteId}'`)
await db.execute(`delete from taxonomies 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 withTaxonomyFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
await withTestDb(async (db) => {
const f = await setupTaxonomyFixture(db)
try {
await fn(db, f)
} finally {
await cleanupTaxonomyFixture(db, f)
}
})
}
describe('分类域约束', () => {
it('taxonomies UNIQUE(site_id, key, object_type):同三元组重复插入拒绝,换 object_type 放行', async () => {
await withTaxonomyFixture(async (db, f) => {
expect(await rejectionMessage(db, `insert into taxonomies (id, site_id, key, object_type, public_base_path)
values (gen_random_uuid(), '${f.siteId}', 'category', 'post', '/category')`))
.toMatch(/taxonomies_uq/)
// 对照组:同 site + 同 key + 换 object_type → 允许(category 可同时服务 post 与 product 两类对象)
await db.execute(`insert into taxonomies (id, site_id, key, object_type, public_base_path)
values (gen_random_uuid(), '${f.siteId}', 'category', 'product', '/product-category')`)
})
})
it('terms UNIQUE(site_id, taxonomy_id, source_id):同三元组重复插入拒绝,换 source_id 放行', async () => {
await withTaxonomyFixture(async (db, f) => {
expect(await rejectionMessage(db, `insert into terms (id, site_id, taxonomy_id, source_id, slug, name, data)
values (gen_random_uuid(), '${f.siteId}', '${f.taxonomyId}', '${f.termSourceId}', 'dup', 'Dup', '{}')`))
.toMatch(/terms_identity_uq/)
// 对照组:同 taxonomy 换 source_id → 允许(唯一键打在来源身份三元组上,非 taxonomy 级唯一)
await db.execute(`insert into terms (id, site_id, taxonomy_id, source_id, slug, name, data)
values (gen_random_uuid(), '${f.siteId}', '${f.taxonomyId}', 'term-other-${f.termSourceId}', 'other', 'Other', '{}')`)
})
})
it('content_terms 复合主键 (site_id, content_entry_id, term_id):同三元组重复插入拒绝', async () => {
await withTaxonomyFixture(async (db, f) => {
await db.execute(`insert into content_terms (site_id, content_entry_id, term_id, is_primary)
values ('${f.siteId}', '${f.entryId}', '${f.termId}', false)`)
expect(await rejectionMessage(db, `insert into content_terms (site_id, content_entry_id, term_id, is_primary)
values ('${f.siteId}', '${f.entryId}', '${f.termId}', true)`))
.toMatch(/content_terms_site_id_content_entry_id_term_id_pk/)
})
})
it('terms.parent_term_id 自引用 FK:指向不存在 term 的插入被拒绝,指向真实 term 放行', async () => {
await withTaxonomyFixture(async (db, f) => {
expect(await rejectionMessage(db, `insert into terms (id, site_id, taxonomy_id, source_id, slug, name, parent_term_id, data)
values (gen_random_uuid(), '${f.siteId}', '${f.taxonomyId}', 'orphan-1', 'orphan', 'Orphan', gen_random_uuid(), '{}')`))
.toMatch(/terms_parent_term_id_terms_id_fk/)
// 对照组:parent 指向本站真实 term → 允许(层级是自引用 FK 的合法用法,非空 parent 不影响唯一键)
await db.execute(`insert into terms (id, site_id, taxonomy_id, source_id, slug, name, parent_term_id, data)
values (gen_random_uuid(), '${f.siteId}', '${f.taxonomyId}', 'child-1', 'child', 'Child', '${f.termId}', '{}')`)
})
})
it('content_terms.is_primary 布尔列存在且可写(Yoast 主分类)', async () => {
await withTaxonomyFixture(async (db, f) => {
const col = (await db.execute(`select data_type from information_schema.columns
where table_name = 'content_terms' and column_name = 'is_primary'`)) as any
expect(col[0].data_type).toBe('boolean')
await db.execute(`insert into content_terms (site_id, content_entry_id, term_id, is_primary)
values ('${f.siteId}', '${f.entryId}', '${f.termId}', true)`)
const before = (await db.execute(`select is_primary from content_terms
where site_id = '${f.siteId}' and content_entry_id = '${f.entryId}'`)) as any
expect(before[0].is_primary).toBe(true)
await db.execute(`update content_terms set is_primary = false
where site_id = '${f.siteId}' and content_entry_id = '${f.entryId}'`)
const after = (await db.execute(`select is_primary from content_terms
where site_id = '${f.siteId}' and content_entry_id = '${f.entryId}'`)) as any
expect(after[0].is_primary).toBe(false)
})
})
})