// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const repository = vi.hoisted(() => ({ createConversation: vi.fn(), createMessage: vi.fn(), getRetryPrompt: vi.fn(), listGatewayMessages: vi.fn(), ownedConversationExists: vi.fn(), updateConversationModel: vi.fn(), updateMessage: vi.fn(), })); const gateway = vi.hoisted(() => ({ streamGatewayResponse: vi.fn() })); const attachments = vi.hoisted(() => ({ attachPendingAttachments: vi.fn() })); vi.mock("server-only", () => ({})); vi.mock("@/lib/db/pool", () => ({ getPool: () => ({}) })); vi.mock("@/lib/db/conversations", () => repository); vi.mock("@/lib/db/attachments", () => attachments); vi.mock("@/lib/chat/gateway", () => ({ GatewayError: class GatewayError extends Error { constructor( readonly status: 403 | 404 | 429 | 502 | 503, message: string, ) { super(message); } }, streamGatewayResponse: gateway.streamGatewayResponse, })); import { POST } from "@/app/api/chat/route"; import { signAuthToken } from "@/lib/auth/server"; beforeEach(() => { vi.stubEnv("JWT_SECRET", "ai-control-center-local-development-secret-2026"); vi.stubEnv("JWT_ISSUER", "corp-ui"); vi.stubEnv("JWT_AUDIENCE", "aegida-gate"); vi.stubEnv("AEGIDA_TENANT_ID", "tenant-1"); for (const mock of Object.values(repository)) mock.mockReset(); gateway.streamGatewayResponse.mockReset(); attachments.attachPendingAttachments.mockReset().mockResolvedValue([]); repository.ownedConversationExists.mockResolvedValue(true); repository.createConversation.mockResolvedValue("conversation-1"); repository.createMessage.mockResolvedValueOnce("user-1").mockResolvedValueOnce("assistant-1"); repository.listGatewayMessages.mockResolvedValue([{ role: "user", content: "Привет" }]); repository.updateMessage.mockResolvedValue(undefined); gateway.streamGatewayResponse.mockResolvedValue(streamOf(["Готово"])); }); afterEach(() => vi.unstubAllEnvs()); describe("POST /api/chat", () => { it("returns 401 before attempting payload validation", async () => { const response = await POST(new Request("http://localhost/api/chat", { method: "POST", body: "{" })); expect(response.status).toBe(401); }); it("rejects the legacy client-supplied message history", async () => { const response = await POST(await authenticatedRequest({ model: "chatgpt", messages: [] })); expect(response.status).toBe(400); expect(repository.createConversation).not.toHaveBeenCalled(); }); it("does not disclose or stream a conversation owned by another user", async () => { repository.ownedConversationExists.mockResolvedValue(false); const response = await POST( await authenticatedRequest({ conversationId: "other-chat", model: "chatgpt", content: "Привет" }), ); expect(response.status).toBe(404); expect(gateway.streamGatewayResponse).not.toHaveBeenCalled(); }); it("persists a user turn, creates a streaming assistant shell, and streams the gateway", async () => { const request = await authenticatedRequest({ model: "qwen", content: "Привет" }); const authorization = request.headers.get("Authorization"); const response = await POST(request); expect(response.status).toBe(200); expect(response.headers.get("X-Conversation-Id")).toBe("conversation-1"); expect(response.headers.get("X-User-Message-Id")).toBe("user-1"); expect(response.headers.get("X-Assistant-Message-Id")).toBe("assistant-1"); expect(await response.text()).toBe("Готово"); await vi.waitFor(() => expect(repository.updateMessage).toHaveBeenCalledWith({}, "assistant-1", "Готово", "complete")); expect(gateway.streamGatewayResponse).toHaveBeenCalledWith( { model: "qwen", messages: [{ role: "user", content: "Привет" }] }, authorization, expect.any(AbortSignal), ); }); it("marks the assistant shell as failed when the gateway cannot start", async () => { gateway.streamGatewayResponse.mockRejectedValue(new Error("gateway unavailable")); const response = await POST(await authenticatedRequest({ model: "chatgpt", content: "Привет" })); expect(response.status).toBe(503); expect(repository.updateMessage).toHaveBeenCalledWith({}, "assistant-1", "", "error"); }); it("relays only the safe FinOPS admission status and message", async () => { const { GatewayError } = await import("@/lib/chat/gateway"); gateway.streamGatewayResponse.mockRejectedValue( new GatewayError(429, "Лимит токенов исчерпан"), ); const response = await POST( await authenticatedRequest({ model: "qwen", content: "private-user-prompt" }), ); expect(response.status).toBe(429); expect(await response.json()).toEqual({ error: "Лимит токенов исчерпан" }); expect(response.headers.get("X-Conversation-Id")).toBe("conversation-1"); expect(response.headers.get("X-User-Message-Id")).toBe("user-1"); expect(response.headers.get("X-Assistant-Message-Id")).toBe("assistant-1"); expect(repository.updateMessage).toHaveBeenCalledWith({}, "assistant-1", "", "error"); }); it("forwards native Gate file parts and supports attachment-only user turns", async () => { const gatewayMessages = [ { role: "user", content: [{ type: "file", file: { file_id: "file-opaque_123" } }], }, ]; repository.listGatewayMessages.mockResolvedValue(gatewayMessages); const request = await authenticatedRequest({ model: "auto", attachmentIds: ["00000000-0000-4000-8000-000000000077"], }); const response = await POST(request); await response.text(); expect(repository.createMessage).toHaveBeenNthCalledWith( 1, {}, "conversation-1", "user", "", "complete", ); expect(attachments.attachPendingAttachments).toHaveBeenCalledWith( {}, "00000000-0000-4000-8000-000000000001", "user-1", ["00000000-0000-4000-8000-000000000077"], ); expect(gateway.streamGatewayResponse).toHaveBeenCalledWith( { model: "auto", messages: gatewayMessages }, request.headers.get("Authorization"), expect.any(AbortSignal), ); expect(JSON.stringify(gateway.streamGatewayResponse.mock.calls)).not.toContain("url"); }); }); function streamOf(chunks: string[]) { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); controller.close(); }, }); } async function authenticatedRequest(body: unknown): Promise { const token = await signAuthToken({ id: "00000000-0000-4000-8000-000000000001", email: "demo@example.com", externalUserId: "42", }); return new Request("http://localhost/api/chat", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); }