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 重命名及类型变更
This commit is contained in:
@@ -41,17 +41,18 @@ describe("模型数据访问层", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text", "reasoning"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
},
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } })
|
||||
.model;
|
||||
const model = (
|
||||
result as { model: { capabilities: string[]; externalId: string; name: string; providerId: string } }
|
||||
).model;
|
||||
expect(model.name).toBe("GPT-4o");
|
||||
expect(model.modelId).toBe("gpt-4o");
|
||||
expect(model.externalId).toBe("gpt-4o");
|
||||
expect(model.providerId).toBe(providerId);
|
||||
expect(model.capabilities).toEqual(["text", "reasoning"]);
|
||||
});
|
||||
@@ -63,7 +64,7 @@ describe("模型数据访问层", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId: "nonexistent",
|
||||
},
|
||||
@@ -77,10 +78,10 @@ describe("模型数据访问层", () => {
|
||||
test("同一供应商下模型 ID 唯一", () => {
|
||||
withDb((db) => {
|
||||
const providerId = seedProvider(db);
|
||||
createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "gpt-4o", name: "Model1", providerId }, createNoopLogger());
|
||||
const result = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "Model2", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
@@ -94,12 +95,12 @@ describe("模型数据访问层", () => {
|
||||
const p2 = seedProvider(db, "P2");
|
||||
const r1 = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 },
|
||||
{ capabilities: ["text"], externalId: "same-id", name: "M1", providerId: p1 },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const r2 = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 },
|
||||
{ capabilities: ["text"], externalId: "same-id", name: "M2", providerId: p2 },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in r1).toBe(false);
|
||||
@@ -112,7 +113,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const result = createModel(
|
||||
db,
|
||||
{ capabilities: [], modelId: "test", name: "Test", providerId },
|
||||
{ capabilities: [], externalId: "test", name: "Test", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
@@ -124,9 +125,9 @@ describe("模型数据访问层", () => {
|
||||
withDb((db) => {
|
||||
const p1 = seedProvider(db, "P1");
|
||||
const p2 = seedProvider(db, "P2");
|
||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
||||
|
||||
const all = listModels(db, { page: 1, pageSize: 20 });
|
||||
expect(all.total).toBe(3);
|
||||
@@ -144,7 +145,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "GPT-4o", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -168,7 +169,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "原名", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -186,7 +187,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "删除测试", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -203,9 +204,9 @@ describe("模型数据访问层", () => {
|
||||
withDb((db) => {
|
||||
const p1 = seedProvider(db, "P1");
|
||||
const p2 = seedProvider(db, "P2");
|
||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
||||
|
||||
expect(getModelsByProviderId(db, p1)).toBe(2);
|
||||
expect(getModelsByProviderId(db, p2)).toBe(1);
|
||||
@@ -220,8 +221,8 @@ describe("模型数据访问层", () => {
|
||||
{
|
||||
capabilities: ["text"],
|
||||
contextLength: 128000,
|
||||
externalId: "gpt-4o",
|
||||
maxOutputTokens: 4096,
|
||||
modelId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
},
|
||||
|
||||
@@ -132,16 +132,13 @@ describe("项目数据访问层", () => {
|
||||
const result = archiveProject(db, id, createNoopLogger());
|
||||
expect("error" in result).toBe(false);
|
||||
|
||||
const archived = (result as { project: { archivedAt: null | string; status: string } }).project;
|
||||
const archived = (result as { project: { status: string } }).project;
|
||||
expect(archived.status).toBe("archived");
|
||||
expect(archived.archivedAt).not.toBeNull();
|
||||
|
||||
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as {
|
||||
archived_at: null | string;
|
||||
const row = db.query("SELECT status FROM projects WHERE id = ?").get(id) as {
|
||||
status: string;
|
||||
};
|
||||
expect(row.status).toBe("archived");
|
||||
expect(row.archived_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +162,8 @@ describe("项目数据访问层", () => {
|
||||
const result = restoreProject(db, id, createNoopLogger());
|
||||
expect("error" in result).toBe(false);
|
||||
|
||||
const restored = (result as { project: { archivedAt: null | string; status: string } }).project;
|
||||
const restored = (result as { project: { status: string } }).project;
|
||||
expect(restored.status).toBe("active");
|
||||
expect(restored.archivedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
154
tests/server/db/schema.test.ts
Normal file
154
tests/server/db/schema.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
241
tests/server/db/soft-delete.test.ts
Normal file
241
tests/server/db/soft-delete.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,12 @@ async function patchConversationViaHandler(req: Request, db: Database): Promise<
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string {
|
||||
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", externalId = "gpt-4o"): string {
|
||||
const result = createModel(
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId,
|
||||
externalId,
|
||||
name: modelName,
|
||||
providerId,
|
||||
},
|
||||
@@ -111,7 +111,7 @@ describe("聊天 API 路由", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("无可用模型时返回 400", async () => {
|
||||
test("无可用模型时创建会话 modelId 为 null", async () => {
|
||||
const handle = createMigratedMemoryTestDatabase("chat-create-no-model");
|
||||
try {
|
||||
const db = handle.db;
|
||||
@@ -123,9 +123,9 @@ describe("聊天 API 路由", () => {
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createConversationViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("模型");
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { conversation: Conversation };
|
||||
expect(body.conversation.modelId).toBeNull();
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
|
||||
@@ -22,7 +22,7 @@ function createTestModel(db: Database, pName: string, providerId?: string): Mode
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
externalId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
name: pName,
|
||||
providerId: pid,
|
||||
},
|
||||
@@ -93,7 +93,7 @@ describe("models API routes", () => {
|
||||
const req = new Request("http://localhost/api/models", {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["text", "reasoning"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
}),
|
||||
@@ -104,7 +104,7 @@ describe("models API routes", () => {
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { model: Model };
|
||||
expect(body.model.name).toBe("GPT-4o");
|
||||
expect(body.model.modelId).toBe("gpt-4o");
|
||||
expect(body.model.externalId).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,10 +142,10 @@ describe("models API routes", () => {
|
||||
test("GET /api/models filter by capabilities", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const p = seedProvider(db, "CapP");
|
||||
createModel(db, { capabilities: ["text"], modelId: "text-1", name: "TextModel", providerId: p }, LOG);
|
||||
createModel(db, { capabilities: ["text"], externalId: "text-1", name: "TextModel", providerId: p }, LOG);
|
||||
createModel(
|
||||
db,
|
||||
{ capabilities: ["reasoning"], modelId: "reasoning-1", name: "ReasoningModel", providerId: p },
|
||||
{ capabilities: ["reasoning"], externalId: "reasoning-1", name: "ReasoningModel", providerId: p },
|
||||
LOG,
|
||||
);
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("models API routes", () => {
|
||||
const req = new Request("http://localhost/api/models", {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["invalid-cap"],
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId,
|
||||
}),
|
||||
@@ -248,7 +248,7 @@ describe("models API routes", () => {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["text"],
|
||||
contextLength: 0,
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId,
|
||||
}),
|
||||
@@ -274,7 +274,7 @@ describe("models API routes", () => {
|
||||
const providerId = seedProvider(db);
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
body: JSON.stringify({ modelId: "gpt-4o", providerId }),
|
||||
body: JSON.stringify({ externalId: "gpt-4o", providerId }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -289,7 +289,7 @@ describe("models API routes", () => {
|
||||
test("POST /api/models/test 缺少 providerId 返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
body: JSON.stringify({ modelId: "gpt-4o" }),
|
||||
body: JSON.stringify({ externalId: "gpt-4o" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe("models API routes", () => {
|
||||
test("POST /api/models/test 不存在的供应商返回 404", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
body: JSON.stringify({ modelId: "gpt-4o", providerId: "nonexistent" }),
|
||||
body: JSON.stringify({ externalId: "gpt-4o", providerId: "nonexistent" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("供应商 API 路由", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: provider.id,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user