aegida-console / app / api / chat / route.test.ts
route.test.ts
Raw
// @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),
  });
}