test: harden AI analysis path
This commit is contained in:
343
app/api/ai/ai-routes.test.ts
Normal file
343
app/api/ai/ai-routes.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import {
|
||||
createKnowledgeEntry,
|
||||
deleteKnowledgeEntry,
|
||||
listKnowledgeEntries,
|
||||
updateKnowledgeEntry,
|
||||
} from "@/modules/ai/server/knowledge";
|
||||
import type { ElderAiAnalysisHistoryItem, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
vi.mock("@/modules/core/server/auth", () => ({
|
||||
requirePermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/audit", () => ({
|
||||
recordAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/analysis", () => ({
|
||||
generateElderAiAnalysis: vi.fn(),
|
||||
listElderAiAnalyses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/knowledge", () => ({
|
||||
createKnowledgeEntry: vi.fn(),
|
||||
deleteKnowledgeEntry: vi.fn(),
|
||||
listKnowledgeEntries: vi.fn(),
|
||||
updateKnowledgeEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "AI Admin",
|
||||
email: "ai-admin@example.com",
|
||||
avatarUrl: "",
|
||||
role: "org_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const organization: NonNullable<AuthContext["organization"]> = {
|
||||
id: "org-1",
|
||||
name: "TeaCare Home",
|
||||
slug: "teacare",
|
||||
status: "active",
|
||||
registrationEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcHasClientSecret: false,
|
||||
oidcScopes: "",
|
||||
oidcRedirectUri: "",
|
||||
oidcAvatarClaim: "",
|
||||
oidcAutoProvision: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createAuthContext(): AuthContext {
|
||||
return {
|
||||
account,
|
||||
organization,
|
||||
organizations: [
|
||||
{
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
status: organization.status,
|
||||
registrationEnabled: organization.registrationEnabled,
|
||||
oidcEnabled: organization.oidcEnabled,
|
||||
roleLabel: "Admin",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
membership: {
|
||||
id: "membership-1",
|
||||
accountId: account.id,
|
||||
organizationId: organization.id,
|
||||
roleId: "role-1",
|
||||
roleKey: "org_admin",
|
||||
roleLabel: "Admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
},
|
||||
permissions: ["ai:read", "elder:read", "knowledge:read", "knowledge:manage"],
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: account.id,
|
||||
activeOrganizationId: organization.id,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const analysis: ElderAiAnalysisHistoryItem = {
|
||||
id: "analysis-1",
|
||||
elderId: "elder-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
createdAt: "2026-07-02T08:00:00.000Z",
|
||||
restricted: false,
|
||||
result: {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
title: "Increase night checks",
|
||||
priority: "high",
|
||||
rationale: "Additional observation reduces missed night wandering events.",
|
||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
dataGaps: [],
|
||||
citations: [
|
||||
{
|
||||
id: "resident-1",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1",
|
||||
title: "Resident risk notes",
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
},
|
||||
],
|
||||
confidence: 0.74,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const knowledgeInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Keep walkways clear and increase night rounds after wandering events.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const knowledgeEntry: KnowledgeEntry = {
|
||||
...knowledgeInput,
|
||||
id: "knowledge-1",
|
||||
createdAt: "2026-07-02T08:00:00.000Z",
|
||||
updatedAt: "2026-07-02T08:00:00.000Z",
|
||||
};
|
||||
|
||||
function allowAuth(): void {
|
||||
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext() });
|
||||
}
|
||||
|
||||
function deniedAuth(reason = "权限不足", status = 403): { success: false; response: Response } {
|
||||
return {
|
||||
success: false,
|
||||
response: Response.json({ success: false, reason }, { status }),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/ai/knowledge", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
allowAuth();
|
||||
});
|
||||
|
||||
describe("AI API routes", () => {
|
||||
it("requires ai:read before elder analysis services are reached", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { GET } = await import("./elders/[id]/analyses/route");
|
||||
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body).toEqual({ success: false, reason: "权限不足" });
|
||||
expect(requirePermission).toHaveBeenCalledTimes(1);
|
||||
expect(requirePermission).toHaveBeenCalledWith("ai:read", expect.objectContaining({ targetId: "elder-1" }));
|
||||
expect(listElderAiAnalyses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires elder:read after ai:read before listing elder analysis history", async () => {
|
||||
vi.mocked(requirePermission)
|
||||
.mockResolvedValueOnce({ success: true, context: createAuthContext() })
|
||||
.mockResolvedValueOnce(deniedAuth("权限不足", 403));
|
||||
|
||||
const { GET } = await import("./elders/[id]/analyses/route");
|
||||
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(2, "elder:read", expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
targetId: "elder-1",
|
||||
}));
|
||||
expect(listElderAiAnalyses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates analysis service failures with the service status", async () => {
|
||||
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
|
||||
|
||||
const { POST } = await import("./elders/[id]/analyses/route");
|
||||
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toEqual({ success: false, reason: "AI_API_KEY 未配置" });
|
||||
expect(generateElderAiAnalysis).toHaveBeenCalledWith(createAuthContext(), "elder-1");
|
||||
});
|
||||
|
||||
it("returns structured success for generated analysis", async () => {
|
||||
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: true, data: { analysis } });
|
||||
|
||||
const { POST } = await import("./elders/[id]/analyses/route");
|
||||
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body).toEqual({ success: true, reason: "AI 分析已生成", analysis });
|
||||
});
|
||||
|
||||
it("requires knowledge:read before listing knowledge entries", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { GET } = await import("./knowledge/route");
|
||||
const response = await GET();
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenCalledWith("knowledge:read", expect.objectContaining({ action: "ai.knowledge.list" }));
|
||||
expect(listKnowledgeEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires knowledge:manage before creating knowledge entries", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenCalledWith("knowledge:manage", expect.objectContaining({ action: "ai.knowledge.create" }));
|
||||
expect(createKnowledgeEntry).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid knowledge bodies before calling the service or audit log", async () => {
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput, title: " " }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body).toEqual({ success: false, reason: "知识标题不能为空" });
|
||||
expect(createKnowledgeEntry).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates knowledge service failures with the service status", async () => {
|
||||
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库向量生成失败", status: 500 });
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toEqual({ success: false, reason: "知识库向量生成失败" });
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns created knowledge entries and records create audit success", async () => {
|
||||
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body).toEqual({ success: true, reason: "知识库条目已创建", entry: knowledgeEntry });
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actor: account,
|
||||
organizationId: "org-1",
|
||||
action: "ai.knowledge.create",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: "knowledge-1",
|
||||
result: "success",
|
||||
}));
|
||||
});
|
||||
|
||||
it("records update and delete audit success only after knowledge mutations succeed", async () => {
|
||||
vi.mocked(updateKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
vi.mocked(deleteKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
|
||||
const { PATCH, DELETE } = await import("./knowledge/[id]/route");
|
||||
const patchResponse = await PATCH(jsonRequest({ ...knowledgeInput }), {
|
||||
params: Promise.resolve({ id: "knowledge-1" }),
|
||||
});
|
||||
const deleteResponse = await DELETE(new Request("http://localhost/api/ai/knowledge/knowledge-1", { method: "DELETE" }), {
|
||||
params: Promise.resolve({ id: "knowledge-1" }),
|
||||
});
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.update", result: "success" }));
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.delete", result: "success" }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user