aegida-console / app / api / attachments / route.test.ts
route.test.ts
Raw
// @vitest-environment node

import { beforeEach, describe, expect, it, vi } from "vitest";

const { authenticateRequest, createPendingAttachment, deleteGateFile, uploadGateFile } =
  vi.hoisted(() => ({
    authenticateRequest: vi.fn(),
    createPendingAttachment: vi.fn(),
    deleteGateFile: vi.fn(),
    uploadGateFile: vi.fn(),
  }));

vi.mock("server-only", () => ({}));
vi.mock("@/lib/db/pool", () => ({ getPool: () => ({}) }));
vi.mock("@/lib/auth/server", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/auth/server")>()),
  authenticateRequest,
}));
vi.mock("@/lib/db/attachments", () => ({ createPendingAttachment }));
vi.mock("@/lib/chat/gate-files", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/chat/gate-files")>()),
  deleteGateFile,
  uploadGateFile,
}));

import { POST } from "@/app/api/attachments/route";
import { AuthError } from "@/lib/auth/server";

beforeEach(() => {
  authenticateRequest.mockReset().mockResolvedValue({
    id: "00000000-0000-4000-8000-000000000001",
    email: "alice@example.com",
  });
  uploadGateFile.mockReset().mockResolvedValue({
    id: "file-opaque_123",
    filename: "brief.txt",
    bytes: 5,
  });
  deleteGateFile.mockReset().mockResolvedValue(undefined);
  createPendingAttachment.mockReset().mockResolvedValue({
    id: "00000000-0000-4000-8000-000000000077",
    gateFileId: "file-opaque_123",
    objectKey: null,
    name: "brief.txt",
    contentType: "text/plain",
    size: 5,
  });
});

describe("POST /api/attachments", () => {
  it("uploads the validated File to Gate before persisting opaque metadata", async () => {
    const request = requestWithFile();
    const authorization = request.headers.get("Authorization");

    const response = await POST(request);

    expect(response.status).toBe(201);
    expect(await response.json()).toEqual({
      attachment: {
        id: "00000000-0000-4000-8000-000000000077",
        name: "brief.txt",
        contentType: "text/plain",
        size: 5,
      },
    });
    expect(uploadGateFile).toHaveBeenCalledWith(
      expect.objectContaining({ name: "../brief.txt", size: 5, type: "text/plain" }),
      authorization,
      expect.any(AbortSignal),
    );
    expect(createPendingAttachment).toHaveBeenCalledWith(
      expect.anything(),
      "00000000-0000-4000-8000-000000000001",
      {
        name: "brief.txt",
        contentType: "text/plain",
        size: 5,
        gateFileId: "file-opaque_123",
      },
    );
    expect(uploadGateFile.mock.invocationCallOrder[0]).toBeLessThan(
      createPendingAttachment.mock.invocationCallOrder[0],
    );
  });

  it("deletes the uploaded Gate file with the user token when DB persistence fails", async () => {
    createPendingAttachment.mockRejectedValue(new Error("private-db-sentinel"));
    const request = requestWithFile();
    const authorization = request.headers.get("Authorization");

    const response = await POST(request);

    expect(response.status).toBe(503);
    expect(JSON.stringify(await response.json())).not.toContain("private-db-sentinel");
    expect(deleteGateFile).toHaveBeenCalledWith(
      "file-opaque_123",
      authorization,
      expect.any(AbortSignal),
    );
  });

  it("rejects unauthorized callers before uploading", async () => {
    authenticateRequest.mockRejectedValue(new AuthError());

    const response = await POST(requestWithFile());

    expect(response.status).toBe(401);
    expect(uploadGateFile).not.toHaveBeenCalled();
  });
});

function requestWithFile(): Request {
  const form = new FormData();
  form.append("file", new File(["brief"], "../brief.txt", { type: "text/plain" }));
  return new Request("http://localhost/api/attachments", {
    method: "POST",
    headers: { Authorization: "Bearer exact-user-jwt" },
    body: form,
  });
}