- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at - models.model_id 重命名为 external_id,消除语义混淆 - conversations.model_id 改为可空(模型为建议而非绑定) - messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联 - 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除) - 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试 - 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role) - DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护 - 路由/前端/测试全量适配 externalId 重命名及类型变更
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||
|
||
import type { TestDatabaseHandle } from "../../helpers";
|
||
|
||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||
|
||
interface ForeignKey {
|
||
from: string;
|
||
id: number;
|
||
match: string;
|
||
on_delete: string;
|
||
on_update: string;
|
||
seq: number;
|
||
table: string;
|
||
to: string;
|
||
}
|
||
|
||
interface IndexInfo {
|
||
cid: number;
|
||
name: string;
|
||
seq: number;
|
||
}
|
||
|
||
interface IndexListEntry {
|
||
name: string;
|
||
origin: string;
|
||
partial: number;
|
||
seq: number;
|
||
unique: number;
|
||
}
|
||
|
||
interface TableColumn {
|
||
cid: number;
|
||
dflt_value: null | string;
|
||
name: string;
|
||
notnull: number;
|
||
pk: number;
|
||
type: string;
|
||
}
|
||
|
||
const BUSINESS_TABLES = ["conversations", "materials", "messages", "models", "projects", "providers"] as const;
|
||
|
||
const CHECK_CONSTRAINTS: Record<string, { column: string; invalidValue: string; validValue: string }> = {
|
||
materials: { column: "status", invalidValue: "'invalid_status'", validValue: "'pending'" },
|
||
messages: { column: "role", invalidValue: "'invalid_role'", validValue: "'user'" },
|
||
projects: { column: "status", invalidValue: "'invalid_status'", validValue: "'active'" },
|
||
providers: { column: "type", invalidValue: "'invalid_type'", validValue: "'openai-compatible'" },
|
||
} as const;
|
||
|
||
const FK_INDEX_REQUIRED: Record<string, readonly string[]> = {
|
||
conversations: ["model_id", "project_id"],
|
||
materials: ["project_id"],
|
||
messages: ["conversation_id"],
|
||
models: ["provider_id"],
|
||
} as const;
|
||
|
||
const TABLES_WITH_FK = Object.keys(FK_INDEX_REQUIRED);
|
||
|
||
describe("schema 契约", () => {
|
||
let handle!: TestDatabaseHandle;
|
||
|
||
beforeAll(() => {
|
||
handle = createMigratedMemoryTestDatabase("schema-contract");
|
||
});
|
||
|
||
afterAll(() => {
|
||
handle.cleanup();
|
||
});
|
||
|
||
describe("基础列(id / created_at / updated_at / deleted_at)", () => {
|
||
for (const table of BUSINESS_TABLES) {
|
||
test(`${table} 包含全部基础列且约束正确`, () => {
|
||
const columns = handle.db.query(`PRAGMA table_info(${table})`).all() as TableColumn[];
|
||
const colMap = new Map(columns.map((c) => [c.name, c]));
|
||
|
||
const idCol = colMap.get("id");
|
||
expect(idCol, `${table} 缺少 id 列`).toBeDefined();
|
||
expect(idCol!.pk, `${table}.id 必须为主键`).toBe(1);
|
||
|
||
const createdAtCol = colMap.get("created_at");
|
||
expect(createdAtCol, `${table} 缺少 created_at 列`).toBeDefined();
|
||
expect(createdAtCol!.notnull, `${table}.created_at 必须 NOT NULL`).toBe(1);
|
||
|
||
const updatedAtCol = colMap.get("updated_at");
|
||
expect(updatedAtCol, `${table} 缺少 updated_at 列`).toBeDefined();
|
||
expect(updatedAtCol!.notnull, `${table}.updated_at 必须 NOT NULL`).toBe(1);
|
||
|
||
const deletedAtCol = colMap.get("deleted_at");
|
||
expect(deletedAtCol, `${table} 缺少 deleted_at 列`).toBeDefined();
|
||
expect(deletedAtCol!.notnull, `${table}.deleted_at 必须可空`).toBe(0);
|
||
expect(deletedAtCol!.pk, `${table}.deleted_at 不可为主键`).toBe(0);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe("外键索引", () => {
|
||
for (const table of TABLES_WITH_FK) {
|
||
const fkColumns = FK_INDEX_REQUIRED[table]!;
|
||
|
||
test(`${table} 外键列 ${fkColumns.join(", ")} 均有索引`, () => {
|
||
const indexes = handle.db.query(`PRAGMA index_list(${table})`).all() as IndexListEntry[];
|
||
|
||
const indexedColumns = new Set<string>();
|
||
for (const idx of indexes) {
|
||
const idxCols = handle.db.query(`PRAGMA index_info(${idx.name})`).all() as IndexInfo[];
|
||
for (const col of idxCols) {
|
||
indexedColumns.add(col.name);
|
||
}
|
||
}
|
||
|
||
for (const fkCol of fkColumns) {
|
||
expect(indexedColumns, `${table}.${fkCol} 应有索引`).toContain(fkCol);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe("外键级联策略", () => {
|
||
for (const table of TABLES_WITH_FK) {
|
||
test(`${table} 所有外键使用 NO ACTION`, () => {
|
||
const fks = handle.db.query(`PRAGMA foreign_key_list(${table})`).all() as ForeignKey[];
|
||
|
||
expect(fks.length, `${table} 应至少有一个外键`).toBeGreaterThan(0);
|
||
|
||
for (const fk of fks) {
|
||
expect(
|
||
fk.on_delete.toLowerCase(),
|
||
`${table}.${fk.from} → ${fk.table}.${fk.to} 应为 no action,实际为 ${fk.on_delete}`,
|
||
).toBe("no action");
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe("CHECK 约束(枚举列)", () => {
|
||
for (const [table, spec] of Object.entries(CHECK_CONSTRAINTS)) {
|
||
test(`${table}.${spec.column} 拒绝非法枚举值`, () => {
|
||
handle.db.exec("SAVEPOINT check_test");
|
||
try {
|
||
expect(() => {
|
||
handle.db
|
||
.query(
|
||
`INSERT INTO ${table} (id, ${spec.column}${table === "messages" ? ", conversation_id, created_at, updated_at" : table === "materials" ? ", project_id, associated_date, description, created_at, updated_at" : table === "providers" ? ", name, api_key, base_url, created_at, updated_at" : ", name, description, created_at, updated_at"}) VALUES ('__check_test__', ${spec.invalidValue}${table === "messages" ? ", 'conv-x', '2024-01-01', '2024-01-01'" : table === "materials" ? ", 'proj-x', '2024-01-01', '', '2024-01-01', '2024-01-01'" : table === "providers" ? ", 'p', '', '', '2024-01-01', '2024-01-01'" : ", 'pj', '', '2024-01-01', '2024-01-01'"})`,
|
||
)
|
||
.run();
|
||
}).toThrow();
|
||
} finally {
|
||
handle.db.exec("ROLLBACK TO SAVEPOINT check_test");
|
||
handle.db.exec("RELEASE SAVEPOINT check_test");
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|