Files
Alfred/tests/server/db/schema.test.ts
lanyuanxiaoyao db40d04dc5 refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化
- 全表新增 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 重命名及类型变更
2026-06-05 01:02:23 +08:00

155 lines
5.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.
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");
}
});
}
});
});