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); }); }); }); });