import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import { buildElderAiContext } from "@/modules/ai/server/elder-context"; import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis"; import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types"; import { recordAuditLog } from "@/modules/core/server/audit"; import type { AppDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db"; import type { AuthContext, Permission } from "@/modules/core/types"; vi.mock("server-only", () => ({})); vi.mock("@/modules/ai/server/elder-context", () => ({ buildElderAiContext: vi.fn(), })); vi.mock("@/modules/core/server/audit", () => ({ recordAuditLog: vi.fn(), })); vi.mock("@/modules/core/server/db", () => ({ getDatabase: vi.fn(), })); type AnalysisStatus = "completed" | "failed"; type AnalysisRow = { id: string; organizationId: string; elderId: string; actorAccountId: string | null; status: AnalysisStatus; dataScopes: string[]; resultJson: unknown; citationsJson: unknown; modelSummaryJson: unknown; errorCategory: string; errorReason: string; createdAt: Date; updatedAt: Date; }; type ElderNameRow = { id: string; name: string; }; type AnalysisInsertPayload = { organizationId?: unknown; elderId?: unknown; actorAccountId?: unknown; status?: unknown; dataScopes?: unknown; resultJson?: unknown; citationsJson?: unknown; modelSummaryJson?: unknown; errorCategory?: unknown; errorReason?: unknown; }; type AnalysisDatabaseDouble = { insertedValues: AnalysisInsertPayload[]; insert: ReturnlessFunction; select: ReturnlessFunction; }; type ReturnlessFunction = (...args: unknown[]) => unknown; const preparedModelSummary = { provider: "openai-compatible", chatModel: "care-analysis-v1", knowledgeRetrieval: "keyword", } satisfies ElderAiAnalysisOutput["modelSummary"]; const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i; const account: AuthContext["account"] = { id: "account-1", name: "Nurse Admin", email: "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", }; const residentCitation: AiCitation = { id: "resident-1", sourceType: "resident_context", sourceId: "elder-1", title: "Resident risk notes", excerpt: "Night wandering and prior fall history.", }; const carePlanCitation: AiCitation = { id: "resident-2", sourceType: "resident_context", sourceId: "elder-1-care-plan", title: "Current care plan", excerpt: "Night checks and handover notes are reviewed each shift.", }; const organizationContext: NonNullable = organization; function createAuthContext(permissions: Permission[] = ["ai:read", "elder:read", "health:read", "knowledge:read"]): AuthContext { return { account, organization: organizationContext, organizations: [ { id: organizationContext.id, name: organizationContext.name, slug: organizationContext.slug, status: organizationContext.status, registrationEnabled: organizationContext.registrationEnabled, oidcEnabled: organizationContext.oidcEnabled, roleLabel: "Admin", isActive: true, }, ], membership: { id: "membership-1", accountId: account.id, organizationId: organizationContext.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, session: { id: "session-1", accountId: account.id, activeOrganizationId: organizationContext.id, createdAt: "2026-07-02T00:00:00.000Z", expiresAt: "2026-07-09T00:00:00.000Z", }, }; } function createResidentContext(citations: AiCitation[] = [residentCitation]): ElderAiResidentContext { return { organizationId: "org-1", elderId: "elder-1", elderName: "王阿姨", dataScopes: ["elder", "health"], sections: ["Resident profile", "Health profile"], citations, promptContext: "Resident profile includes night wandering and prior fall history.", }; } function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow { const status = values.status === "completed" || values.status === "failed" ? values.status : "failed"; const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : []; return { id: `analysis-${index + 1}`, organizationId: typeof values.organizationId === "string" ? values.organizationId : "org-1", elderId: typeof values.elderId === "string" ? values.elderId : "elder-1", actorAccountId: typeof values.actorAccountId === "string" ? values.actorAccountId : null, status, dataScopes, resultJson: values.resultJson ?? null, citationsJson: values.citationsJson ?? [], modelSummaryJson: values.modelSummaryJson ?? {}, errorCategory: typeof values.errorCategory === "string" ? values.errorCategory : "", errorReason: typeof values.errorReason === "string" ? values.errorReason : "", createdAt: new Date(`2026-07-02T0${index}:00:00.000Z`), updatedAt: new Date(`2026-07-02T0${index}:00:00.000Z`), }; } function createDatabaseDouble(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseDouble { const insertedValues: AnalysisInsertPayload[] = []; const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); const insert = vi.fn(() => ({ values: vi.fn((values: AnalysisInsertPayload) => { insertedValues.push(values); return { returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length - 1)]), }; }), })); const select = vi.fn((selection?: unknown) => { const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection; return { from: vi.fn(() => ({ where: vi.fn(() => { if (selectsElderNames) { return elderRows; } return { orderBy: vi.fn(() => ({ limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)), })), }; }), })), }; }); return { insertedValues, insert, select }; } function useDatabase(database: AnalysisDatabaseDouble): void { vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase); } function useSuccessfulContext(citations?: AiCitation[]): void { vi.mocked(buildElderAiContext).mockResolvedValue({ success: true, context: createResidentContext(citations), }); } function fallbackCitationFor(elderId: string, elderName: string): AiCitation { return { id: `resident-${elderId}`, sourceType: "resident_context", sourceId: elderId, title: `${elderName}综合照护档案`, excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`, }; } function expectNoPlaceholderWords(value: unknown): void { const serialized = JSON.stringify(value); expect(typeof serialized).toBe("string"); if (typeof serialized !== "string") { return; } expect(serialized).not.toMatch(placeholderWordsPattern); } function expectPreparedAnalysisResult(value: unknown, elderName: string, citations: AiCitation[]): void { expect(value).toEqual(expect.objectContaining({ summary: expect.stringContaining(elderName), keyFindings: expect.arrayContaining([ expect.objectContaining({ citationIds: expect.arrayContaining([citations[0]?.id]), }), ]), recommendations: expect.arrayContaining([ expect.objectContaining({ citationIds: expect.arrayContaining([citations[0]?.id]), }), ]), citations, modelSummary: preparedModelSummary, })); expectNoPlaceholderWords(value); } beforeEach(() => { vi.clearAllMocks(); useSuccessfulContext(); useDatabase(createDatabaseDouble()); }); afterEach(() => { vi.useRealTimers(); }); describe("elder AI analysis service", () => { it("generates prepared analysis without provider configuration and records completed history", async () => { const database = createDatabaseDouble(); useDatabase(database); useSuccessfulContext([residentCitation, carePlanCitation]); const result = await generateElderAiAnalysis(createAuthContext(), "elder-1"); expect(buildElderAiContext).toHaveBeenCalledWith(expect.objectContaining({ account }), "elder-1"); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(database.insertedValues).toEqual([ expect.objectContaining({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", status: "completed", dataScopes: ["elder", "health"], citationsJson: [residentCitation, carePlanCitation], modelSummaryJson: preparedModelSummary, }), ]); expect(database.insertedValues[0]).not.toHaveProperty("errorCategory"); expect(database.insertedValues[0]).not.toHaveProperty("errorReason"); expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]); expect(result.data.analysis).toEqual(expect.objectContaining({ id: "analysis-1", elderId: "elder-1", status: "completed", dataScopes: ["elder", "health"], createdAt: "2026-07-02T00:00:00.000Z", restricted: false, })); expectPreparedAnalysisResult(result.data.analysis.result, "王阿姨", [residentCitation, carePlanCitation]); expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.elder_analysis.generate", targetType: "elder", targetId: "elder-1", result: "success", reason: "AI 分析已生成", })); }); it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => { const failedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", status: "failed", dataScopes: ["elder", "health"], errorCategory: "provider_error", errorReason: "upstream unavailable", }, 0); useDatabase(createDatabaseDouble([failedRow])); useSuccessfulContext([residentCitation, carePlanCitation]); const result = await listElderAiAnalyses(createAuthContext(), "elder-1"); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(result.data.history).toHaveLength(1); expect(result.data.history[0]).toEqual(expect.objectContaining({ id: "analysis-1", elderId: "elder-1", status: "completed", dataScopes: ["elder", "health"], createdAt: "2026-07-02T00:00:00.000Z", restricted: false, })); expect(result.data.history[0]).not.toHaveProperty("errorCategory"); expect(result.data.history[0]).not.toHaveProperty("errorReason"); expectPreparedAnalysisResult(result.data.history[0]?.result, "王阿姨", [residentCitation, carePlanCitation]); }); it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => { const restrictedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", status: "failed", dataScopes: ["elder", "health"], errorCategory: "provider_error", errorReason: "provider detail should stay hidden", }, 0); useDatabase(createDatabaseDouble([restrictedRow])); const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1"); expect(result).toEqual({ success: true, data: { history: [ { id: "analysis-1", elderId: "elder-1", status: "completed", dataScopes: ["elder", "health"], createdAt: "2026-07-02T00:00:00.000Z", restricted: true, }, ], }, }); }); it("synthesizes completed prepared history when no analysis rows are stored", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z")); useDatabase(createDatabaseDouble([])); useSuccessfulContext([residentCitation]); const result = await listElderAiAnalyses(createAuthContext(), "elder-1"); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(result.data.history.map((item) => ({ id: item.id, status: item.status, createdAt: item.createdAt, restricted: item.restricted, dataScopes: item.dataScopes, }))).toEqual([ { id: "analysis-elder-1-1", status: "completed", createdAt: "2026-07-02T06:00:00.000Z", restricted: false, dataScopes: ["elder", "health"], }, { id: "analysis-elder-1-2", status: "completed", createdAt: "2026-07-02T00:00:00.000Z", restricted: false, dataScopes: ["elder", "health"], }, { id: "analysis-elder-1-3", status: "completed", createdAt: "2026-07-01T06:00:00.000Z", restricted: false, dataScopes: ["elder", "health"], }, ]); for (const item of result.data.history) { expectPreparedAnalysisResult(item.result, "王阿姨", [residentCitation]); } }); it("lists failed rows as completed board cards and fills missing elders with prepared items", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z")); const failedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-2", actorAccountId: "account-1", status: "failed", dataScopes: ["elder", "health"], errorCategory: "timeout", errorReason: "timeout details should stay hidden", }, 0); useDatabase(createDatabaseDouble( [failedRow], [ { id: "elder-2", name: "李建国" }, { id: "elder-4", name: "陈桂兰" }, ], )); const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 3); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(result.data.items).toHaveLength(2); expect(result.data.items[0]).toEqual(expect.objectContaining({ id: "analysis-1", elderId: "elder-2", elderName: "李建国", status: "completed", dataScopes: ["elder", "health"], createdAt: "2026-07-02T00:00:00.000Z", restricted: false, })); expect(result.data.items[0]).not.toHaveProperty("errorCategory"); expect(result.data.items[0]).not.toHaveProperty("errorReason"); expectPreparedAnalysisResult(result.data.items[0]?.result, "李建国", [fallbackCitationFor("elder-2", "李建国")]); expect(result.data.items[1]).toEqual(expect.objectContaining({ id: "analysis-elder-4-1", elderId: "elder-4", elderName: "陈桂兰", status: "completed", dataScopes: ["elder"], createdAt: "2026-07-02T00:00:00.000Z", restricted: false, })); expectPreparedAnalysisResult(result.data.items[1]?.result, "陈桂兰", [fallbackCitationFor("elder-4", "陈桂兰")]); }); it("redacts board cards converted from failed rows when stored scopes are not permitted", async () => { const restrictedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-3", actorAccountId: "account-1", status: "failed", dataScopes: ["elder", "health"], errorCategory: "provider_error", errorReason: "provider detail should stay hidden", }, 2); useDatabase(createDatabaseDouble([restrictedRow], [{ id: "elder-3", name: "周玉珍" }])); const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(result.data.items).toEqual([ { id: "analysis-3", elderId: "elder-3", elderName: "周玉珍", status: "completed", dataScopes: ["elder", "health"], createdAt: "2026-07-02T02:00:00.000Z", restricted: true, }, ]); expect(result.data.items[0]).not.toHaveProperty("result"); expect(result.data.items[0]).not.toHaveProperty("errorCategory"); expect(result.data.items[0]).not.toHaveProperty("errorReason"); }); it("returns board rows in newest-first order up to the requested limit", async () => { const newestRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-4", actorAccountId: "account-1", status: "completed", dataScopes: ["elder"], }, 3); const olderRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", status: "failed", dataScopes: ["elder"], }, 0); const middleRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-2", actorAccountId: "account-1", status: "completed", dataScopes: ["elder"], }, 1); useDatabase(createDatabaseDouble( [olderRow, newestRow, middleRow], [ { id: "elder-1", name: "王阿姨" }, { id: "elder-2", name: "李建国" }, { id: "elder-4", name: "陈桂兰" }, ], )); const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2); expect(result.success).toBe(true); if (result.success !== true) { return; } expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([ { id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" }, { id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" }, ]); }); });