Files
浅念 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

120 lines
6.9 KiB
TypeScript
Raw Permalink 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.
// 路由域集成测试:route_registry 唯一索引 route_exact_uq (site_id, host, exact_route_key,
// query_key) NULLS NOT DISTINCT(0005 迁移手写 SQL——drizzle-kit 0.31 不生成该子句)。
// 断言组(V2 设计 ADR-007 双键语义):
// 1. exact_route_key 区分大小写——/About 与 /about 是两条路由,均可插入(设计语义,不是 bug);
// 2. 纯路径唯一:同 (site, host, exact_route_key) 且 query_key 均 NULL 的第二条插入拒绝
// ——这正是 NULLS NOT DISTINCT 的意义(默认 NULLS DISTINCT 下两行 NULL 不冲突,挡不住);
// 3. query_key 参与唯一:同 exact key 下 query_key='p' 与 NULL 共存;同 'p' 第二条拒绝;
// 4. route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)往返 + 第七值拒绝;
// 5. query_policy enum 四值(ignore/exact/preserve/dynamic)往返 + 第五值拒绝。
// Task 13 起夹具走公共 helpers(createSiteFixture/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀建 site + finally 反序清理(route_registry → sites);本表无不可变触发器。
import { describe, it, expect } from 'vitest'
import { createSiteFixture, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Fixture = { siteId: string; rand: string }
async function setupRoutesFixture(db: Db): Promise<Fixture> {
const site = await createSiteFixture(db, 'routes')
return { siteId: site.siteId, rand: site.rand }
}
// 反序清理:route_registry(无不可变触发器,直接删)→ sites(级联挡在 site 前,先删子表)。
async function cleanupRoutesFixture(db: Db, f: Fixture) {
await db.execute(`delete from route_registry where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`)
}
async function withRoutesFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
await withTestDb(async (db) => {
const f = await setupRoutesFixture(db)
try {
await fn(db, f)
} finally {
await cleanupRoutesFixture(db, f)
}
})
}
// 探测插入:除显式指定的列外全部换新值(随机 suffix 的 id/path/collision_key),保证被测
// 唯一索引 (site_id, host, exact_route_key, query_key) 是唯一可能的冲突源;默认 query_key=NULL
// (纯路径路由)。其余 NOT NULL 列填合法基线值。
function routeProbe(f: Fixture, overrides: {
host?: string
exactRouteKey?: string
queryKey?: string | null
routeKind?: string
queryPolicy?: string
} = {}) {
const suffix = Math.random().toString(36).slice(2, 8)
const key = overrides.exactRouteKey ?? `/probe-${suffix}`
const host = overrides.host ?? 'example.com'
const queryKeySql = overrides.queryKey == null ? 'null' : `'${overrides.queryKey}'`
return `insert into route_registry (id, site_id, host, source_path_raw, route_path, exact_route_key, collision_key, query_policy, query_key, source_status, target_status, route_kind, indexable, priority, verification_status)
values (gen_random_uuid(), '${f.siteId}', '${host}', '/raw-${suffix}', '${key}', '${key}', 'collision-${suffix}', '${overrides.queryPolicy ?? 'ignore'}', ${queryKeySql}, '200', '200', '${overrides.routeKind ?? 'html'}', true, 100, 'pending') returning id`
}
describe('路由域约束', () => {
it('exact_route_key 区分大小写(ADR-007 语义):同站同 host 下 /About 与 /about 两条均可插入', async () => {
await withRoutesFixture(async (db, f) => {
await db.execute(routeProbe(f, { exactRouteKey: '/About' }))
await db.execute(routeProbe(f, { exactRouteKey: '/about' }))
// 两条都真实入库,各自独立成行(exact key 不折叠大小写——这是请求匹配键的设计语义);
// 排序在 JS 侧做,不依赖库端 collation(默认排序 /about 在 /About 前)
const rows = (await db.execute(`select exact_route_key from route_registry
where site_id = '${f.siteId}' and exact_route_key in ('/About', '/about')`)) as any
expect(rows.map((r: any) => r.exact_route_key).sort()).toEqual(['/About', '/about'])
})
})
it('纯路径唯一(NULLS NOT DISTINCT):同 (site, host, exact_route_key) 且 query_key 均 NULL 的第二条拒绝', async () => {
await withRoutesFixture(async (db, f) => {
await db.execute(routeProbe(f, { exactRouteKey: '/dup-null' }))
// 若索引是默认 NULLS DISTINCT,两行 query_key=NULL 不冲突、会静默放行——本断言专守这一点
expect(await rejectionMessage(db, routeProbe(f, { exactRouteKey: '/dup-null' })))
.toMatch(/route_exact_uq/)
})
})
it('query_key 参与唯一:同 exact key 下 NULL 与 p 共存;同 query_key 第二条拒绝', async () => {
await withRoutesFixture(async (db, f) => {
// 纯路径路由(NULL)与查询路由('p')是不同路由,合法共存
await db.execute(routeProbe(f, { exactRouteKey: '/query', queryKey: null }))
await db.execute(routeProbe(f, { exactRouteKey: '/query', queryKey: 'p' }))
// 同 exact key 同 query_key 的第二条 → 唯一索引拒绝(身份断言匹配索引名)
expect(await rejectionMessage(db, routeProbe(f, { exactRouteKey: '/query', queryKey: 'p' })))
.toMatch(/route_exact_uq/)
})
})
it('route_kind enum 六值(html/asset/redirect/gone/dynamic/unresolved)可写往返,第七值拒绝', async () => {
await withRoutesFixture(async (db, f) => {
const inserted = (await db.execute(routeProbe(f))) as any
const routeId = inserted[0].id
for (const kind of ['asset', 'redirect', 'gone', 'dynamic', 'unresolved', 'html']) {
await db.execute(`update route_registry set route_kind = '${kind}' where id = '${routeId}'`)
const row = (await db.execute(`select route_kind from route_registry where id = '${routeId}'`)) as any
expect(row[0].route_kind).toBe(kind)
}
// enum 在库:类型化列拒绝六值之外的任意字符串
expect(await rejectionMessage(db, `update route_registry set route_kind = 'bogus' where id = '${routeId}'`))
.toMatch(/route_kind/)
})
})
it('query_policy enum 四值(ignore/exact/preserve/dynamic)可写往返,第五值拒绝', async () => {
await withRoutesFixture(async (db, f) => {
const inserted = (await db.execute(routeProbe(f))) as any
const routeId = inserted[0].id
for (const policy of ['exact', 'preserve', 'dynamic', 'ignore']) {
await db.execute(`update route_registry set query_policy = '${policy}' where id = '${routeId}'`)
const row = (await db.execute(`select query_policy from route_registry where id = '${routeId}'`)) as any
expect(row[0].query_policy).toBe(policy)
}
// enum 在库:类型化列拒绝四值之外的任意字符串
expect(await rejectionMessage(db, `update route_registry set query_policy = 'bogus' where id = '${routeId}'`))
.toMatch(/route_query_policy/)
})
})
})