Files
Alfred/tests/server/db/soft-delete.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

242 lines
9.8 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 type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { createConversation, createMessage, deleteConversation } from "../../../src/server/db/conversations";
import { createMaterial, deleteMaterial, listMaterials } from "../../../src/server/db/materials";
import { createModel, deleteModel, listModels } from "../../../src/server/db/models";
import { createProject, deleteProject, listProjects, updateProject } from "../../../src/server/db/projects";
import { createProvider, deleteProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const log = createNoopLogger();
function withDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("soft-delete-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("软删除与级联", () => {
describe("V1: 删除 project 级联软删 conversations + materials + messages", () => {
test("归档项目软删除后,关联会话/消息/素材均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P1" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "gpt-4",
name: "GPT",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "hi", conversationId: convId, role: "user" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "M1" }, log);
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [projectId]);
const del = deleteProject(db, projectId, log);
expect("error" in del).toBe(false);
const projectRow = db.query("SELECT deleted_at FROM projects WHERE id = ?").get(projectId) as {
deleted_at: null | string;
};
const convRow = db.query("SELECT deleted_at FROM conversations WHERE id = ?").get(convId) as {
deleted_at: null | string;
};
const messageRow = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").get(convId) as {
deleted_at: null | string;
};
const materialRow = db.query("SELECT deleted_at FROM materials WHERE project_id = ?").get(projectId) as {
deleted_at: null | string;
};
expect(projectRow.deleted_at).not.toBeNull();
expect(convRow.deleted_at).not.toBeNull();
expect(messageRow.deleted_at).not.toBeNull();
expect(materialRow.deleted_at).not.toBeNull();
});
});
});
describe("V2: 删除 conversation 级联软删 messages", () => {
test("会话软删除后,其下消息均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P2" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov2", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "claude",
name: "Claude",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "m1", conversationId: convId, role: "user" }, log);
createMessage(db, { content: "m2", conversationId: convId, role: "assistant" }, log);
const del = deleteConversation(db, convId, log);
expect("error" in del).toBe(false);
const messages = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").all(convId) as Array<{
deleted_at: null | string;
}>;
expect(messages.length).toBe(2);
expect(messages.every((m) => m.deleted_at !== null)).toBe(true);
});
});
});
describe("V3: paginateQuery softDelete 自动过滤已删除行", () => {
test("listProjects 自动排除软删除项目", () => {
withDb((db) => {
createProject(db, { name: "Alive1" }, log);
createProject(db, { name: "Alive2" }, log);
const toDelete = createProject(db, { name: "Dying" }, log);
const dyingId = (toDelete as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [dyingId]);
deleteProject(db, dyingId, log);
const result = listProjects(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((p) => p.name).sort()).toEqual(["Alive1", "Alive2"]);
});
});
test("listMaterials 自动排除软删除素材", () => {
withDb((db) => {
const proj = createProject(db, { name: "PM" }, log);
const projectId = (proj as { project: { id: string } }).project.id;
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "K1" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-02", description: "K2" }, log);
const toDelete = createMaterial(db, projectId, { associatedDate: "2024-01-03", description: "K3" }, log);
const materialId = (toDelete as { material: { id: string } }).material.id;
deleteMaterial(db, projectId, materialId, log);
const result = listMaterials(db, projectId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.description).sort()).toEqual(["K1", "K2"]);
});
});
test("listModels 自动排除软删除模型", () => {
withDb((db) => {
const prov = createProvider(db, { apiKey: "sk", baseUrl: "https://x.com", name: "Px", type: "openai" }, log);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "a", name: "A", providerId }, log);
createModel(db, { capabilities: ["text"], externalId: "b", name: "B", providerId }, log);
const dying = createModel(db, { capabilities: ["text"], externalId: "c", name: "C", providerId }, log);
deleteModel(db, (dying as { model: { id: string } }).model.id, log);
const result = listModels(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.name).sort()).toEqual(["A", "B"]);
});
});
});
describe("V4: 应用层唯一约束(软删后同名复活)", () => {
test("软删除项目后可以创建同名项目", () => {
withDb((db) => {
const first = createProject(db, { name: "SameName" }, log);
const firstId = (first as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [firstId]);
deleteProject(db, firstId, log);
const second = createProject(db, { name: "SameName" }, log);
expect("error" in second).toBe(false);
});
});
test("未删除同名项目存在时创建失败409", () => {
withDb((db) => {
createProject(db, { name: "ClashName" }, log);
const result = createProject(db, { name: "ClashName" }, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("更新项目名称不与自身冲突", () => {
withDb((db) => {
const created = createProject(db, { name: "SelfUpdate" }, log);
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "SelfUpdate" }, log);
expect("error" in result).toBe(false);
});
});
});
describe("V5: 删除 provider 时阻止(存在未删除 model", () => {
test("存在未删除 model 时删除 provider 返回错误", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "BlockProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "blocking-model", name: "BlockM", providerId }, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("所有 model 已软删除后可以删除 provider", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "FreeProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
const m = createModel(db, { capabilities: ["text"], externalId: "free-model", name: "FreeM", providerId }, log);
deleteModel(db, (m as { model: { id: string } }).model.id, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(false);
});
});
});
});