// @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<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
}
async function authenticatedRequest(body: unknown): Promise<Request> {
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),
});
}