mike/backend/src/__tests__/integration/projects.routes.test.ts
willchen96 80d14b86fc test: drop mocks of removed getUserIdFromRequest helper
The integration tests landed on main after this branch was cut and
mocked the helper this PR deletes. Vitest factory mocks don't validate
against the real module shape, so the extra key was harmless but
misleading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:39:42 +08:00

414 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns we want to reconfigure per-test.
// ---------------------------------------------------------------------------
const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({
checkProjectAccess: vi.fn(),
deleteUserProjects: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach;
// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the
// per-table result, and rpc() resolves to a per-call result. Insert payloads
// are recorded so tests can assert on normalisation (lowercasing / dedupe).
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
rpc: QueryResult;
tables: Record<string, QueryResult>;
inserts: { table: string; payload: unknown }[];
};
function resetSupabaseState() {
supabaseState = {
rpc: { data: [], error: null },
tables: {},
inserts: [],
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.insert = vi.fn((payload: unknown) => {
supabaseState.inserts.push({ table, payload });
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
}));
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));
// Every export of lib/access must be present — other routers (chat, documents,
// downloads, tabular) import from it at app load.
vi.mock("../../lib/access", () => ({
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
listAccessibleProjectIds: vi.fn(async () => []),
}));
// user router imports all four cleanup helpers at module load.
vi.mock("../../lib/userDataCleanup", () => ({
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
deleteAllUserChats: vi.fn(async () => {}),
deleteAllUserTabularReviews: vi.fn(async () => {}),
deleteUserAccountData: vi.fn(async () => {}),
}));
// Version-path enrichment hits the DB in real life; no-op it so the route
// responses are driven purely by the documents/projects table stubs.
vi.mock("../../lib/documentVersions", () => ({
attachActiveVersionPaths: vi.fn(async () => {}),
attachLatestVersionNumbers: vi.fn(async () => {}),
loadActiveVersion: vi.fn(async () => null),
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
describe("projects.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
deleteUserProjects.mockResolvedValue(1);
});
// ── GET /projects (overview) ──────────────────────────────────────────
describe("GET /projects", () => {
it("returns the overview rows from the RPC", async () => {
supabaseState.rpc = {
data: [{ id: "p1", name: "Alpha" }],
error: null,
};
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]);
});
it("returns 500 with detail when the RPC errors", async () => {
supabaseState.rpc = { data: null, error: { message: "boom" } };
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("boom");
});
});
// ── POST /projects (create) ───────────────────────────────────────────
describe("POST /projects", () => {
it("returns 400 when name is missing/blank", async () => {
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: " " });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("name is required");
});
it("returns 400 when sharing the project with yourself", async () => {
// The authed user's email is u1@test.local; supplying it (in any
// case) must be rejected.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Beta", shared_with: ["U1@Test.Local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("creates the project (201) and normalises shared_with", async () => {
// Sharing requires each recipient to have a mirrored user_profiles
// row (findMissingUserEmails); seed both emails so validation
// passes and the create path proceeds.
supabaseState.tables.user_profiles = {
data: [{ email: "a@x.com" }, { email: "b@x.com" }],
error: null,
};
supabaseState.tables.projects = {
data: {
id: "p9",
name: "Gamma",
user_id: "u1",
shared_with: ["a@x.com", "b@x.com"],
},
error: null,
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({
name: " Gamma ",
shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "],
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "p9", documents: [] });
// The insert payload should be lowercased, deduped, trimmed and
// the name trimmed.
const insert = supabaseState.inserts.find(
(i) => i.table === "projects",
);
expect(insert?.payload).toMatchObject({
name: "Gamma",
shared_with: ["a@x.com", "b@x.com"],
});
});
it("returns 400 when a shared_with recipient is not a Mike user", async () => {
// No user_profiles rows seeded → findMissingUserEmails reports the
// recipient as unknown and the create is rejected before insert.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Gamma", shared_with: ["ghost@x.com"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"ghost@x.com does not belong to a Mike user.",
);
expect(
supabaseState.inserts.find((i) => i.table === "projects"),
).toBeUndefined();
});
it("returns 500 when the insert errors", async () => {
supabaseState.tables.projects = {
data: null,
error: { message: "insert failed" },
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Delta" });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("insert failed");
});
});
// ── GET /projects/:projectId (detail, inline access) ──────────────────
describe("GET /projects/:projectId", () => {
it("returns 404 when the project does not exist", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 404 when the caller is neither owner nor shared", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["other@x.com"],
},
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("grants access to a shared member (is_owner false)", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["u1@test.local"],
},
error: null,
};
supabaseState.tables.documents = { data: [], error: null };
supabaseState.tables.project_subfolders = { data: [], error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: "p1", is_owner: false });
});
it("returns 200 with documents/folders/is_owner when owned", async () => {
supabaseState.tables.projects = {
data: { id: "p1", user_id: "u1", shared_with: null },
error: null,
};
supabaseState.tables.documents = {
data: [{ id: "d1", user_id: "u1" }],
error: null,
};
supabaseState.tables.project_subfolders = {
data: [{ id: "f1" }],
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "p1",
is_owner: true,
documents: [{ id: "d1" }],
folders: [{ id: "f1" }],
});
});
});
// ── GET /projects/:projectId/documents (checkProjectAccess guard) ─────
describe("GET /projects/:projectId/documents", () => {
it("returns 404 when checkProjectAccess denies access", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
it("returns 200 with documents when access is granted", async () => {
supabaseState.tables.documents = {
data: [{ id: "d1" }, { id: "d2" }],
error: null,
};
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]);
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
});
// ── PATCH /projects/:projectId (sharing normalisation) ────────────────
describe("PATCH /projects/:projectId", () => {
it("returns 400 when sharing the project with yourself", async () => {
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ shared_with: ["u1@test.local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("returns 404 when the update matches no owned project", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ name: "Renamed" });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
});
// ── DELETE /projects/:projectId ───────────────────────────────────────
describe("DELETE /projects/:projectId", () => {
it("returns 404 when nothing was deleted", async () => {
deleteUserProjects.mockResolvedValue(0);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 204 when the project is deleted", async () => {
deleteUserProjects.mockResolvedValue(1);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(204);
// Signature is deleteUserProjects(db, userId, [projectId]).
expect(deleteUserProjects).toHaveBeenCalledWith(
expect.anything(),
"u1",
["p1"],
);
});
it("returns 500 when deletion throws", async () => {
deleteUserProjects.mockRejectedValue(new Error("cascade failed"));
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("cascade failed");
});
});
});