f1c181de02
- 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>
120 lines
6.9 KiB
TypeScript
120 lines
6.9 KiB
TypeScript
// 路由域集成测试: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/)
|
||
})
|
||
})
|
||
})
|