Skip to content

Commit 4d8f7b6

Browse files
authored
test: add goals CRUD API coverage
Closes #1907 Signed-off-by: Abhisumat Kashyap <abhisumatkashyap@gmail.com>
1 parent 7feb78e commit 4d8f7b6

1 file changed

Lines changed: 285 additions & 0 deletions

File tree

test/goals-crud.test.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { GET, POST } from "@/app/api/goals/route";
3+
import { PATCH, DELETE } from "@/app/api/goals/[id]/route";
4+
5+
const mocks = vi.hoisted(() => ({
6+
getServerSession: vi.fn(),
7+
resolveAppUser: vi.fn(),
8+
supabaseFrom: vi.fn(),
9+
dispatchToAllWebhooks: vi.fn(),
10+
}));
11+
12+
vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession }));
13+
vi.mock("@/lib/auth", () => ({ authOptions: {} }));
14+
vi.mock("@/lib/resolve-user", () => ({ resolveAppUser: mocks.resolveAppUser }));
15+
vi.mock("@/lib/supabase", () => ({
16+
supabaseAdmin: { from: mocks.supabaseFrom },
17+
}));
18+
vi.mock("@/lib/webhooks", () => ({
19+
dispatchToAllWebhooks: mocks.dispatchToAllWebhooks,
20+
}));
21+
vi.mock("@/lib/sanitize", () => ({
22+
stripHtml: vi.fn((s: string) => s),
23+
}));
24+
25+
function buildGoal(overrides: Record<string, unknown> = {}) {
26+
return {
27+
id: "goal-1",
28+
user_id: "user-1",
29+
title: "Test goal",
30+
target: 10,
31+
current: 0,
32+
unit: "commits",
33+
recurrence: "none",
34+
deadline: null,
35+
period_start: null,
36+
created_at: "2026-01-01T00:00:00Z",
37+
...overrides,
38+
};
39+
}
40+
41+
function makePostRequest(body: unknown): [Request] {
42+
return [
43+
new Request("http://localhost/api/goals", {
44+
method: "POST",
45+
headers: { "Content-Type": "application/json" },
46+
body: JSON.stringify(body),
47+
}),
48+
];
49+
}
50+
51+
function makePatchRequest(body: unknown, goalId = "goal-1"): [Request, { params: { id: string } }] {
52+
return [
53+
new Request(`http://localhost/api/goals/${goalId}`, {
54+
method: "PATCH",
55+
headers: { "Content-Type": "application/json" },
56+
body: JSON.stringify(body),
57+
}),
58+
{ params: { id: goalId } },
59+
];
60+
}
61+
62+
function makeDeleteRequest(goalId = "goal-1"): [Request, { params: { id: string } }] {
63+
return [
64+
new Request(`http://localhost/api/goals/${goalId}`, {
65+
method: "DELETE",
66+
}),
67+
{ params: { id: goalId } },
68+
];
69+
}
70+
71+
describe("GET /api/goals", () => {
72+
beforeEach(() => {
73+
vi.clearAllMocks();
74+
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
75+
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
76+
});
77+
78+
it("returns 401 when there is no session", async () => {
79+
mocks.getServerSession.mockResolvedValue(null);
80+
const res = await GET();
81+
expect(res.status).toBe(401);
82+
});
83+
84+
it("returns 404 when the user cannot be resolved", async () => {
85+
mocks.resolveAppUser.mockResolvedValue(null);
86+
const res = await GET();
87+
expect(res.status).toBe(404);
88+
});
89+
90+
it("returns goals for the authenticated user", async () => {
91+
const goals = [
92+
buildGoal({ id: "goal-1" }),
93+
buildGoal({ id: "goal-2", title: "Second goal" }),
94+
];
95+
const limitFn = vi.fn().mockResolvedValue({ data: goals, error: null });
96+
const orderFn = vi.fn().mockReturnValue({ limit: limitFn });
97+
const eqFn = vi.fn().mockReturnValue({ order: orderFn });
98+
mocks.supabaseFrom.mockReturnValue({
99+
select: vi.fn().mockReturnValue({ eq: eqFn }),
100+
});
101+
const res = await GET();
102+
expect(res.status).toBe(200);
103+
const body = await res.json();
104+
expect(body.goals).toHaveLength(2);
105+
});
106+
107+
it("returns an empty array when the user has no goals", async () => {
108+
const limitFn = vi.fn().mockResolvedValue({ data: [], error: null });
109+
const orderFn = vi.fn().mockReturnValue({ limit: limitFn });
110+
const eqFn = vi.fn().mockReturnValue({ order: orderFn });
111+
mocks.supabaseFrom.mockReturnValue({
112+
select: vi.fn().mockReturnValue({ eq: eqFn }),
113+
});
114+
const res = await GET();
115+
const body = await res.json();
116+
expect(body.goals).toEqual([]);
117+
});
118+
});
119+
120+
describe("POST /api/goals", () => {
121+
beforeEach(() => {
122+
vi.clearAllMocks();
123+
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
124+
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
125+
mocks.dispatchToAllWebhooks.mockResolvedValue(undefined);
126+
});
127+
128+
it("returns 401 when there is no session", async () => {
129+
mocks.getServerSession.mockResolvedValue(null);
130+
const [req] = makePostRequest({ title: "Test", target: 10 });
131+
const res = await POST(req);
132+
expect(res.status).toBe(401);
133+
});
134+
135+
it("creates a goal and returns it with status 201", async () => {
136+
const createdGoal = buildGoal({ title: "New goal", target: 5, unit: "prs" });
137+
mocks.supabaseFrom.mockReturnValue({
138+
select: vi.fn().mockReturnValue({
139+
eq: vi.fn().mockResolvedValue({ count: 0, error: null }),
140+
}),
141+
insert: vi.fn().mockReturnValue({
142+
select: vi.fn().mockReturnValue({
143+
single: vi.fn().mockResolvedValue({ data: createdGoal, error: null }),
144+
}),
145+
}),
146+
});
147+
const [req] = makePostRequest({ title: "New goal", target: 5, unit: "prs" });
148+
const res = await POST(req);
149+
expect(res.status).toBe(201);
150+
const body = await res.json();
151+
expect(body.goal.title).toBe("New goal");
152+
expect(body.goal.target).toBe(5);
153+
});
154+
155+
it("returns 400 when title is omitted", async () => {
156+
const [req] = makePostRequest({ target: 10 });
157+
const res = await POST(req);
158+
expect(res.status).toBe(400);
159+
});
160+
161+
it("returns 400 when target exceeds the maximum", async () => {
162+
const [req] = makePostRequest({ title: "Goal", target: 10001 });
163+
const res = await POST(req);
164+
expect(res.status).toBe(400);
165+
});
166+
167+
it("returns 400 when the user already has the maximum number of goals", async () => {
168+
mocks.supabaseFrom.mockReturnValue({
169+
select: vi.fn().mockReturnValue({
170+
eq: vi.fn().mockResolvedValue({ count: 5, error: null }),
171+
}),
172+
});
173+
const [req] = makePostRequest({ title: "Another goal", target: 10 });
174+
const res = await POST(req);
175+
expect(res.status).toBe(400);
176+
});
177+
});
178+
179+
describe("PATCH /api/goals/[id]", () => {
180+
beforeEach(() => {
181+
vi.clearAllMocks();
182+
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
183+
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
184+
mocks.dispatchToAllWebhooks.mockResolvedValue(undefined);
185+
});
186+
187+
it("returns 401 when there is no session", async () => {
188+
mocks.getServerSession.mockResolvedValue(null);
189+
const [req, ctx] = makePatchRequest({ title: "Updated" });
190+
const res = await PATCH(req, ctx);
191+
expect(res.status).toBe(401);
192+
});
193+
194+
it("returns 404 when the goal does not exist", async () => {
195+
const singleFn = vi.fn().mockResolvedValue({ data: null, error: { message: "not found" } });
196+
const eq2Fn = vi.fn().mockReturnValue({ single: singleFn });
197+
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
198+
mocks.supabaseFrom.mockReturnValue({
199+
select: vi.fn().mockReturnValue({ eq: eq1Fn }),
200+
});
201+
const [req, ctx] = makePatchRequest({ title: "Updated" }, "nonexistent");
202+
const res = await PATCH(req, ctx);
203+
expect(res.status).toBe(404);
204+
});
205+
206+
it("updates the goal title and target", async () => {
207+
const existing = buildGoal({ title: "Original", target: 10, unit: "hours" });
208+
const updated = { ...existing, title: "Updated", target: 20 };
209+
const selectSingle = vi.fn().mockResolvedValue({ data: existing, error: null });
210+
const selectEq2 = vi.fn().mockReturnValue({ single: selectSingle });
211+
const selectEq1 = vi.fn().mockReturnValue({ eq: selectEq2 });
212+
const selectChain = vi.fn().mockReturnValue({ eq: selectEq1 });
213+
const updateSingle = vi.fn().mockResolvedValue({ data: updated, error: null });
214+
const updateSelect = vi.fn().mockReturnValue({ single: updateSingle });
215+
const updateEq2 = vi.fn().mockReturnValue({ select: updateSelect });
216+
const updateEq1 = vi.fn().mockReturnValue({ eq: updateEq2 });
217+
const updateChain = vi.fn().mockReturnValue({ eq: updateEq1 });
218+
mocks.supabaseFrom.mockReturnValue({
219+
select: selectChain,
220+
update: updateChain,
221+
});
222+
const [req, ctx] = makePatchRequest({ title: "Updated", target: 20 });
223+
const res = await PATCH(req, ctx);
224+
expect(res.status).toBe(200);
225+
const body = await res.json();
226+
expect(body.goal.title).toBe("Updated");
227+
expect(body.goal.target).toBe(20);
228+
});
229+
230+
it("returns 400 for an invalid JSON body", async () => {
231+
const req = new Request("http://localhost/api/goals/goal-1", {
232+
method: "PATCH",
233+
headers: { "Content-Type": "application/json" },
234+
body: "not-json",
235+
});
236+
const res = await PATCH(req, { params: { id: "goal-1" } });
237+
expect(res.status).toBe(400);
238+
});
239+
});
240+
241+
describe("DELETE /api/goals/[id]", () => {
242+
beforeEach(() => {
243+
vi.clearAllMocks();
244+
mocks.getServerSession.mockResolvedValue({ githubId: "gh-123", githubLogin: "alice" });
245+
mocks.resolveAppUser.mockResolvedValue({ id: "user-1" });
246+
});
247+
248+
it("returns 401 when there is no session", async () => {
249+
mocks.getServerSession.mockResolvedValue(null);
250+
const [req, ctx] = makeDeleteRequest();
251+
const res = await DELETE(req, ctx);
252+
expect(res.status).toBe(401);
253+
});
254+
255+
it("deletes the goal and returns success", async () => {
256+
const eq2Fn = vi.fn().mockResolvedValue({ data: null, error: null });
257+
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
258+
mocks.supabaseFrom.mockReturnValue({
259+
delete: vi.fn().mockReturnValue({ eq: eq1Fn }),
260+
});
261+
const [req, ctx] = makeDeleteRequest();
262+
const res = await DELETE(req, ctx);
263+
expect(res.status).toBe(200);
264+
const body = await res.json();
265+
expect(body.success).toBe(true);
266+
});
267+
268+
it("returns 500 when the database delete fails", async () => {
269+
const eq2Fn = vi.fn().mockResolvedValue({ data: null, error: { message: "DB error" } });
270+
const eq1Fn = vi.fn().mockReturnValue({ eq: eq2Fn });
271+
mocks.supabaseFrom.mockReturnValue({
272+
delete: vi.fn().mockReturnValue({ eq: eq1Fn }),
273+
});
274+
const [req, ctx] = makeDeleteRequest();
275+
const res = await DELETE(req, ctx);
276+
expect(res.status).toBe(500);
277+
});
278+
279+
it("returns 404 when the user cannot be resolved", async () => {
280+
mocks.resolveAppUser.mockResolvedValue(null);
281+
const [req, ctx] = makeDeleteRequest();
282+
const res = await DELETE(req, ctx);
283+
expect(res.status).toBe(404);
284+
});
285+
});

0 commit comments

Comments
 (0)