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 = { 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): 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> { return (await response.json()) as Record; } 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" })); }); });