test(boss-dashboard): add route handler tests for dashboard and rep-activity
This commit is contained in:
parent
f243132dc0
commit
e9ce5f4fc4
2 changed files with 402 additions and 0 deletions
216
src/app/api/v1/boss-dashboard/rep-activity/route.test.ts
Normal file
216
src/app/api/v1/boss-dashboard/rep-activity/route.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const assertBrowserSession = vi.fn();
|
||||
const getBossRepActivity = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/browser-session", () => ({
|
||||
assertBrowserSession: (...args: unknown[]) => assertBrowserSession(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/boss-dashboard-service", () => ({
|
||||
getBossRepActivity: (...args: unknown[]) => getBossRepActivity(...args),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
|
||||
function authedRequest(url: string) {
|
||||
return new NextRequest(url, {
|
||||
headers: { authorization: "Bearer test-token" },
|
||||
});
|
||||
}
|
||||
|
||||
function unauthedRequest(url: string) {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
// --- Tests ----------------------------------------------------------------
|
||||
|
||||
describe("GET /api/v1/boss-dashboard/rep-activity", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
assertBrowserSession.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("401s when the authorization header is missing", async () => {
|
||||
// assertBrowserSession is what enforces auth; have it throw to mirror the
|
||||
// real production behaviour when a missing header is forwarded.
|
||||
assertBrowserSession.mockImplementation(() => {
|
||||
throw Object.assign(new Error("Not authenticated."), { status: 401 });
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
unauthedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/auth/i);
|
||||
expect(getBossRepActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s when the rep query param is missing", async () => {
|
||||
const res = await GET(
|
||||
authedRequest("http://localhost/api/v1/boss-dashboard/rep-activity"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/Missing rep/);
|
||||
expect(getBossRepActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s when the window query param is not in the allow-list", async () => {
|
||||
const res = await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&window=garbage",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/Invalid window/);
|
||||
expect(getBossRepActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s when the kind query param is not in the allow-list", async () => {
|
||||
const res = await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&kind=garbage",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/Invalid kind/);
|
||||
expect(getBossRepActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 with the service payload (kind=quotes) on success", async () => {
|
||||
getBossRepActivity.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "q-1",
|
||||
number: "Q-2026-08-014",
|
||||
customer: "North Pit",
|
||||
status: "emailed",
|
||||
ageMs: 1_000,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&kind=quotes",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0]).toMatchObject({
|
||||
id: "q-1",
|
||||
number: "Q-2026-08-014",
|
||||
customer: "North Pit",
|
||||
status: "emailed",
|
||||
});
|
||||
expect(body.hasMore).toBe(false);
|
||||
|
||||
// Defaults: window="today", page=1.
|
||||
expect(getBossRepActivity).toHaveBeenCalledWith({
|
||||
rep: "u-1",
|
||||
window: "today",
|
||||
kind: "quotes",
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 200 with the service payload (kind=orders) on success", async () => {
|
||||
getBossRepActivity.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "o-1",
|
||||
number: "ORD-2026-08-009",
|
||||
customer: "Anglo",
|
||||
status: "pending",
|
||||
ageMs: 5_000,
|
||||
},
|
||||
{
|
||||
id: "o-2",
|
||||
number: "ORD-2026-08-008",
|
||||
customer: "Sasol",
|
||||
status: "delivered",
|
||||
ageMs: 10_000,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-2&kind=orders",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.items).toHaveLength(2);
|
||||
expect(body.hasMore).toBe(true);
|
||||
expect(getBossRepActivity).toHaveBeenCalledWith({
|
||||
rep: "u-2",
|
||||
window: "today",
|
||||
kind: "orders",
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards explicit window and page params to the service", async () => {
|
||||
getBossRepActivity.mockResolvedValueOnce({ items: [], hasMore: false });
|
||||
|
||||
const res = await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-3&window=week&kind=quotes&page=4",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getBossRepActivity).toHaveBeenCalledWith({
|
||||
rep: "u-3",
|
||||
window: "week",
|
||||
kind: "quotes",
|
||||
page: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps page to the [1, 1000] range", async () => {
|
||||
getBossRepActivity.mockResolvedValueOnce({ items: [], hasMore: false });
|
||||
|
||||
// page=0 → clamped up to 1.
|
||||
await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=0",
|
||||
),
|
||||
);
|
||||
expect(getBossRepActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1 }),
|
||||
);
|
||||
|
||||
// page=99999 → clamped down to 1000.
|
||||
await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=99999",
|
||||
),
|
||||
);
|
||||
expect(getBossRepActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1000 }),
|
||||
);
|
||||
|
||||
// Garbage page → falls back to 1.
|
||||
await GET(
|
||||
authedRequest(
|
||||
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=abc",
|
||||
),
|
||||
);
|
||||
expect(getBossRepActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
186
src/app/api/v1/boss-dashboard/route.test.ts
Normal file
186
src/app/api/v1/boss-dashboard/route.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const getBossDashboardData = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/boss-dashboard-service", () => ({
|
||||
getBossDashboardData: (...args: unknown[]) => getBossDashboardData(...args),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
|
||||
const ORIGINAL_PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
|
||||
function authedRequest(url: string) {
|
||||
return new NextRequest(url, {
|
||||
headers: { authorization: "Bearer test-token" },
|
||||
});
|
||||
}
|
||||
|
||||
function unauthedRequest(url: string) {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard route does an inline `fetch` to PB's auth-refresh endpoint
|
||||
* (NOT the shared `assertBrowserSession` helper), so we mock globalThis.fetch
|
||||
* here rather than mocking the helper module.
|
||||
*/
|
||||
function mockAuthRefreshOk(user: { id: string; name: string } = { id: "u1", name: "Boss" }) {
|
||||
return vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ record: user }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockAuthRefreshFail(status = 401) {
|
||||
return vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response("nope", { status }));
|
||||
}
|
||||
|
||||
// --- Tests ----------------------------------------------------------------
|
||||
|
||||
describe("GET /api/v1/boss-dashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_POCKETBASE_URL = "http://127.0.0.1:8090";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_PB_URL === undefined) delete process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
else process.env.NEXT_PUBLIC_POCKETBASE_URL = ORIGINAL_PB_URL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("401s when the authorization header is missing", async () => {
|
||||
// No fetch mock — the route should bail before hitting PB.
|
||||
const res = await GET(unauthedRequest("http://localhost/api/v1/boss-dashboard"));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/auth/i);
|
||||
expect(getBossDashboardData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when PB's auth-refresh rejects the token", async () => {
|
||||
mockAuthRefreshFail(401);
|
||||
|
||||
const res = await GET(authedRequest("http://localhost/api/v1/boss-dashboard"));
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/invalid|expired|session/i);
|
||||
expect(getBossDashboardData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("400s when the window query param is not in the allow-list", async () => {
|
||||
mockAuthRefreshOk();
|
||||
|
||||
const res = await GET(
|
||||
authedRequest("http://localhost/api/v1/boss-dashboard?window=garbage"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/Invalid window/);
|
||||
expect(getBossDashboardData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 with the merged dashboard payload + viewerName on success", async () => {
|
||||
mockAuthRefreshOk({ id: "u-boss", name: "Dave" });
|
||||
getBossDashboardData.mockResolvedValueOnce({
|
||||
window: "today",
|
||||
rangeStart: "2026-08-28T00:00:00.000Z",
|
||||
rangeEnd: "2026-08-29T00:00:00.000Z",
|
||||
orderTotals: { total: 7, delivered: 3, invoiced: 1 },
|
||||
reps: [
|
||||
{
|
||||
id: "u1",
|
||||
name: "Alice",
|
||||
isUnknown: false,
|
||||
quotesLoaded: 4,
|
||||
quotesDoneFor: 2,
|
||||
quotesDoneBy: 1,
|
||||
ordersPlaced: 3,
|
||||
},
|
||||
],
|
||||
drivers: [],
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
authedRequest("http://localhost/api/v1/boss-dashboard?window=today"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
// Service payload passed through.
|
||||
expect(body.window).toBe("today");
|
||||
expect(body.rangeStart).toBe("2026-08-28T00:00:00.000Z");
|
||||
expect(body.rangeEnd).toBe("2026-08-29T00:00:00.000Z");
|
||||
expect(body.orderTotals).toEqual({ total: 7, delivered: 3, invoiced: 1 });
|
||||
expect(body.reps).toHaveLength(1);
|
||||
expect(body.reps[0].name).toBe("Alice");
|
||||
|
||||
// viewerName merged from the auth-refresh response.
|
||||
expect(body.viewerName).toBe("Dave");
|
||||
|
||||
// Service was called with the validated window string.
|
||||
expect(getBossDashboardData).toHaveBeenCalledWith({ window: "today" });
|
||||
});
|
||||
|
||||
it("defaults window to 'today' when no window param is given", async () => {
|
||||
mockAuthRefreshOk();
|
||||
getBossDashboardData.mockResolvedValueOnce({
|
||||
window: "today",
|
||||
rangeStart: "2026-08-28T00:00:00.000Z",
|
||||
rangeEnd: "2026-08-29T00:00:00.000Z",
|
||||
orderTotals: { total: 0, delivered: 0, invoiced: 0 },
|
||||
reps: [],
|
||||
drivers: [],
|
||||
});
|
||||
|
||||
const res = await GET(authedRequest("http://localhost/api/v1/boss-dashboard"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(getBossDashboardData).toHaveBeenCalledWith({ window: "today" });
|
||||
});
|
||||
|
||||
it("forwards the 'week' window to the service", async () => {
|
||||
mockAuthRefreshOk();
|
||||
getBossDashboardData.mockResolvedValueOnce({
|
||||
window: "week",
|
||||
rangeStart: "2026-08-24T00:00:00.000Z",
|
||||
rangeEnd: "2026-08-31T00:00:00.000Z",
|
||||
orderTotals: { total: 0, delivered: 0, invoiced: 0 },
|
||||
reps: [],
|
||||
drivers: [],
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
authedRequest("http://localhost/api/v1/boss-dashboard?window=week"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getBossDashboardData).toHaveBeenCalledWith({ window: "week" });
|
||||
});
|
||||
|
||||
it("forwards the 'month' window to the service", async () => {
|
||||
mockAuthRefreshOk();
|
||||
getBossDashboardData.mockResolvedValueOnce({
|
||||
window: "month",
|
||||
rangeStart: "2026-08-01T00:00:00.000Z",
|
||||
rangeEnd: "2026-09-01T00:00:00.000Z",
|
||||
orderTotals: { total: 0, delivered: 0, invoiced: 0 },
|
||||
reps: [],
|
||||
drivers: [],
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
authedRequest("http://localhost/api/v1/boss-dashboard?window=month"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getBossDashboardData).toHaveBeenCalledWith({ window: "month" });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue