Files
cms-website/tests/integration/db.taxonomy.test.ts
T
2026-10-20 19:48:04 +08:00

133 lines
7.5 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 主分类)。
// 生命周期纪律:随机后缀插入 + finally 反序清理,可重复运行不残留脏数据(本域无不可变表,常规删除即可;
// content_terms 依赖 content_entries,fixture 建链照 Task 8 模式只建到 entry 即可)。
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
taxonomyId: string
termId: string
termSourceId: string
}
// 建链(FK 依赖顺序):site → content_entry → taxonomy → term(默认无 parent,层级在各测试内自建)。
async function setupTaxonomyFixture(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(), 'tax-${rand}', 'TaxFixture', 'en', 'https://tax.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 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>) {
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<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('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)
})
})
})