Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions test/goals-crud.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET, POST } from "@/app/api/goals/route";
import { PATCH, DELETE } from "@/app/api/goals/[id]/route";

const mocks = vi.hoisted(() => ({
getServerSession: vi.fn(),
resolveAppUser: vi.fn(),
supabaseFrom: vi.fn(),
dispatchToAllWebhooks: vi.fn(),
}));

vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession }));
vi.mock("@/lib/auth", () => ({ authOptions: {} }));
vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser }));
vi.mock("@/lib/supabase", () => ({
supabaseAdmin: { from: mocks.supabaseFrom },
}));
vi.mock("@/lib/webhooks", () => ({
dispatchToAllWebhooks: mocks.dispatchToAllWebhooks,
}));
vi.mock("@/lib/sanitize", () => ({
stripHtml: vi.fn((s: string) => s),
}));

function buildGoal(overrides: Record<string, unknown> = {}) {
return {
id: "goal-1",
user_id: "user-1",
title: "Test goal",
target: 10,
current: 0,
unit: "commits",
recurrence: "none",
deadline: null,
period_start: null,
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}

function makePostRequest(body: unknown): [Request] {
return [
new Request("http://localhost/api/goals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
];
}

function makePatchRequest(body: unknown, goalId = "goal-1"): [Request, { params: { id: string } }] {
return [
new Request(`http://localhost/api/goals/${goalId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
{ params: { id: goalId } },
];
}

function makeDeleteRequest(goalId = "goal-1"): [Request, { params: { id: string } }] {
return [
new Request(`http://localhost/api/goals/${goalId}`, {
method: "DELETE",
}),
{ params: { id: goalId } },
];
}

describe("GET /api/goals", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
});

it("returns 401 when there is no session", async () => {
mocks.getServerSession.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
});

it("returns 404 when the user cannot be resolved", async () => {
mocks.resolveAppUser.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(404);
});

it("returns goals for the authenticated user", async () => {
const goals = [
buildGoal({ id: "goal-1" }),
buildGoal({ id: "goal-2", title: "Second goal" }),
];
const limitFn = vi.fn().mockResolvedValue({ data: goals, error: null });
const orderFn = vi.fn().mockReturnValue({ limit: limitFn });
const eqFn = vi.fn().mockReturnValue({ order: orderFn });
mocks.supabaseFrom.mockReturnValue({
select: vi.fn().mockReturnValue({ eq: eqFn }),
});
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.goals).toHaveLength(2);
});

it("returns an empty array when the user has no goals", async () => {
const limitFn = vi.fn().mockResolvedValue({ data: [], error: null });
const orderFn = vi.fn().mockReturnValue({ limit: limitFn });
const eqFn = vi.fn().mockReturnValue({ order: orderFn });
mocks.supabaseFrom.mockReturnValue({
select: vi.fn().mockReturnValue({ eq: eqFn }),
});
const res = await GET();
const body = await res.json();
expect(body.goals).toEqual([]);
});
});

describe("POST /api/goals", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
mocks.dispatchToAllWebhooks.mockResolvedValue(undefined);
});

it("returns 401 when there is no session", async () => {
mocks.getServerSession.mockResolvedValue(null);
const [req] = makePostRequest({ title: "Test", target: 10 });
const res = await POST(req);
expect(res.status).toBe(401);
});

it("creates a goal and returns it with status 201", async () => {
const createdGoal = buildGoal({ title: "New goal", target: 5, unit: "prs" });
mocks.supabaseFrom.mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: 0, error: null }),
}),
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: createdGoal, error: null }),
}),
}),
});
const [req] = makePostRequest({ title: "New goal", target: 5, unit: "prs" });
const res = await POST(req);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.goal.title).toBe("New goal");
expect(body.goal.target).toBe(5);
});

it("returns 400 when title is omitted", async () => {
const [req] = makePostRequest({ target: 10 });
const res = await POST(req);
expect(res.status).toBe(400);
});

it("returns 400 when target exceeds the maximum", async () => {
const [req] = makePostRequest({ title: "Goal", target: 10001 });
const res = await POST(req);
expect(res.status).toBe(400);
});

it("returns 400 when the user already has the maximum number of goals", async () => {
mocks.supabaseFrom.mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: 5, error: null }),
}),
});
const [req] = makePostRequest({ title: "Another goal", target: 10 });
const res = await POST(req);
expect(res.status).toBe(400);
});
});

describe("PATCH /api/goals/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
mocks.dispatchToAllWebhooks.mockResolvedValue(undefined);
});

it("returns 401 when there is no session", async () => {
mocks.getServerSession.mockResolvedValue(null);
const [req, ctx] = makePatchRequest({ title: "Updated" });
const res = await PATCH(req, ctx);
expect(res.status).toBe(401);
});

it("returns 404 when the goal does not exist", async () => {
const singleFn = vi.fn().mockResolvedValue({ data: null, error: { message: "not found" } });
const eq2Fn = vi.fn().mockReturnValue({ single: singleFn });
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
mocks.supabaseFrom.mockReturnValue({
select: vi.fn().mockReturnValue({ eq: eq1Fn }),
});
const [req, ctx] = makePatchRequest({ title: "Updated" }, "nonexistent");
const res = await PATCH(req, ctx);
expect(res.status).toBe(404);
});

it("updates the goal title and target", async () => {
const existing = buildGoal({ title: "Original", target: 10, unit: "hours" });
const updated = { ...existing, title: "Updated", target: 20 };
const selectSingle = vi.fn().mockResolvedValue({ data: existing, error: null });
const selectEq2 = vi.fn().mockReturnValue({ single: selectSingle });
const selectEq1 = vi.fn().mockReturnValue({ eq: selectEq2 });
const selectChain = vi.fn().mockReturnValue({ eq: selectEq1 });
const updateSingle = vi.fn().mockResolvedValue({ data: updated, error: null });
const updateSelect = vi.fn().mockReturnValue({ single: updateSingle });
const updateEq2 = vi.fn().mockReturnValue({ select: updateSelect });
const updateEq1 = vi.fn().mockReturnValue({ eq: updateEq2 });
const updateChain = vi.fn().mockReturnValue({ eq: updateEq1 });
mocks.supabaseFrom.mockReturnValue({
select: selectChain,
update: updateChain,
});
const [req, ctx] = makePatchRequest({ title: "Updated", target: 20 });
const res = await PATCH(req, ctx);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.goal.title).toBe("Updated");
expect(body.goal.target).toBe(20);
});

it("returns 400 for an invalid JSON body", async () => {
const req = new Request("http://localhost/api/goals/goal-1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: "not-json",
});
const res = await PATCH(req, { params: { id: "goal-1" } });
expect(res.status).toBe(400);
});
});

describe("DELETE /api/goals/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
});

it("returns 401 when there is no session", async () => {
mocks.getServerSession.mockResolvedValue(null);
const [req, ctx] = makeDeleteRequest();
const res = await DELETE(req, ctx);
expect(res.status).toBe(401);
});

it("deletes the goal and returns success", async () => {
const eq2Fn = vi.fn().mockResolvedValue({ data: null, error: null });
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
mocks.supabaseFrom.mockReturnValue({
delete: vi.fn().mockReturnValue({ eq: eq1Fn }),
});
const [req, ctx] = makeDeleteRequest();
const res = await DELETE(req, ctx);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
});

it("returns 500 when the database delete fails", async () => {
const eq2Fn = vi.fn().mockResolvedValue({ data: null, error: { message: "DB error" } });
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
mocks.supabaseFrom.mockReturnValue({
delete: vi.fn().mockReturnValue({ eq: eq1Fn }),
});
const [req, ctx] = makeDeleteRequest();
const res = await DELETE(req, ctx);
expect(res.status).toBe(500);
});

it("returns 404 when the user cannot be resolved", async () => {
mocks.resolveAppUser.mockResolvedValue(null);
const [req, ctx] = makeDeleteRequest();
const res = await DELETE(req, ctx);
expect(res.status).toBe(404);
});
});
Loading