Skip to content

Commit b3ee208

Browse files
authored
Merge branch 'develop' into new-kids-adults-services-schemas
Signed-off-by: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com>
2 parents 4b09df9 + c48b797 commit b3ee208

6 files changed

Lines changed: 472 additions & 17 deletions

File tree

__tests__/services-actions.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import {
5+
createService,
6+
updateService,
7+
} from "@/app/(authenticated)/services/actions";
8+
9+
// db mock
10+
const insertValues = jest.fn().mockResolvedValue(undefined);
11+
const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock;
12+
13+
const selectLimit = jest.fn();
14+
const selectWhere = jest.fn(() => ({ limit: selectLimit }));
15+
const selectFrom = jest.fn(() => ({ where: selectWhere }));
16+
const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock;
17+
18+
const updateWhere = jest.fn().mockResolvedValue(undefined);
19+
const updateSet = jest.fn(() => ({ where: updateWhere }));
20+
const update = jest.fn(() => ({ set: updateSet })) as jest.Mock;
21+
22+
jest.mock("@/lib/db", () => ({
23+
db: {
24+
insert: (...args: unknown[]) => insert(...args),
25+
select: (...args: unknown[]) => select(...args),
26+
update: (...args: unknown[]) => update(...args),
27+
},
28+
}));
29+
30+
// stripe mock
31+
const createProduct = jest.fn();
32+
const createPrice = jest.fn();
33+
const updateProduct = jest.fn();
34+
const deactivateActivePricesForProduct = jest.fn();
35+
const getStripeServiceData = jest.fn();
36+
37+
jest.mock("@/lib/stripe", () => ({
38+
createProduct: (...args: unknown[]) => createProduct(...args),
39+
createPrice: (...args: unknown[]) => createPrice(...args),
40+
updateProduct: (...args: unknown[]) => updateProduct(...args),
41+
deactivateActivePricesForProduct: (...args: unknown[]) =>
42+
deactivateActivePricesForProduct(...args),
43+
getStripeServiceData: (...args: unknown[]) => getStripeServiceData(...args),
44+
}));
45+
46+
// auth + cache mocks
47+
const requireAdmin = jest.fn();
48+
jest.mock("@/lib/auth/require-admin", () => ({
49+
requireAdmin: (...args: unknown[]) => requireAdmin(...args),
50+
}));
51+
52+
jest.mock("next/cache", () => ({
53+
revalidatePath: jest.fn(),
54+
updateTag: jest.fn(),
55+
}));
56+
57+
const COACH_A = "11111111-1111-1111-1111-111111111111";
58+
const COACH_B = "22222222-2222-2222-2222-222222222222";
59+
const SERVICE_ID = "33333333-3333-3333-3333-333333333333";
60+
61+
function fd(obj: Record<string, string>): FormData {
62+
const f = new FormData();
63+
for (const [k, v] of Object.entries(obj)) f.append(k, v);
64+
return f;
65+
}
66+
67+
beforeEach(() => {
68+
jest.clearAllMocks();
69+
requireAdmin.mockResolvedValue(undefined);
70+
createProduct.mockResolvedValue({ productId: "prod_1" });
71+
createPrice.mockResolvedValue(undefined);
72+
updateProduct.mockResolvedValue(undefined);
73+
deactivateActivePricesForProduct.mockResolvedValue(undefined);
74+
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });
75+
});
76+
77+
describe("createService", () => {
78+
it("rejects a private lesson without a coach", async () => {
79+
const result = await createService(
80+
null,
81+
fd({
82+
title: "1:1 Lesson",
83+
description: "A private session",
84+
type: "private_lessons",
85+
duration_minutes: "60",
86+
price_cad: "50.00",
87+
}),
88+
);
89+
90+
expect(result?.errors?.coach_id).toBeDefined();
91+
expect(insert).not.toHaveBeenCalled();
92+
expect(createProduct).not.toHaveBeenCalled();
93+
});
94+
95+
it("persists the coach when creating a private lesson", async () => {
96+
const result = await createService(
97+
null,
98+
fd({
99+
title: "1:1 Lesson",
100+
description: "A private session",
101+
type: "private_lessons",
102+
duration_minutes: "60",
103+
price_cad: "50.00",
104+
coach_id: COACH_A,
105+
}),
106+
);
107+
108+
expect(result).toEqual({ message: "Service created." });
109+
expect(insertValues).toHaveBeenCalledWith(
110+
expect.objectContaining({ type: "private_lessons", coachId: COACH_A }),
111+
);
112+
});
113+
114+
it("creates a program with no coach", async () => {
115+
const result = await createService(
116+
null,
117+
fd({
118+
title: "Summer Program",
119+
description: "Group program",
120+
type: "programs",
121+
duration_minutes: "60",
122+
price_cad: "100.00",
123+
start_date: "2026-01-01",
124+
end_date: "2026-02-01",
125+
slots: JSON.stringify([{ dayOfWeek: 1, time: "10:00" }]),
126+
}),
127+
);
128+
129+
expect(result).toEqual({ message: "Service created." });
130+
expect(insertValues).toHaveBeenCalledWith(
131+
expect.objectContaining({ type: "programs", coachId: null }),
132+
);
133+
});
134+
});
135+
136+
describe("updateService", () => {
137+
it("reassigns the coach on a private lesson", async () => {
138+
selectLimit.mockResolvedValue([
139+
{
140+
id: SERVICE_ID,
141+
type: "private_lessons",
142+
status: "active",
143+
stripeProductId: "prod_1",
144+
},
145+
]);
146+
147+
const result = await updateService(
148+
null,
149+
fd({ service_id: SERVICE_ID, coach_id: COACH_B }),
150+
);
151+
152+
expect(result).toEqual({ message: "Service updated." });
153+
expect(updateSet).toHaveBeenCalledWith(
154+
expect.objectContaining({ coachId: COACH_B }),
155+
);
156+
});
157+
158+
it("does not recreate the Stripe price when the amount is unchanged", async () => {
159+
selectLimit.mockResolvedValue([
160+
{
161+
id: SERVICE_ID,
162+
type: "private_lessons",
163+
status: "active",
164+
stripeProductId: "prod_1",
165+
},
166+
]);
167+
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });
168+
169+
const result = await updateService(
170+
null,
171+
fd({ service_id: SERVICE_ID, coach_id: COACH_B, price_cad: "50.00" }),
172+
);
173+
174+
expect(result).toEqual({ message: "Service updated." });
175+
expect(deactivateActivePricesForProduct).not.toHaveBeenCalled();
176+
expect(createPrice).not.toHaveBeenCalled();
177+
});
178+
179+
it("recreates the Stripe price when the amount changes", async () => {
180+
selectLimit.mockResolvedValue([
181+
{
182+
id: SERVICE_ID,
183+
type: "private_lessons",
184+
status: "active",
185+
stripeProductId: "prod_1",
186+
},
187+
]);
188+
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });
189+
190+
const result = await updateService(
191+
null,
192+
fd({ service_id: SERVICE_ID, price_cad: "75.00" }),
193+
);
194+
195+
expect(result).toEqual({ message: "Service updated." });
196+
expect(deactivateActivePricesForProduct).toHaveBeenCalled();
197+
expect(createPrice).toHaveBeenCalledWith("prod_1", 7500);
198+
});
199+
200+
it("rejects clearing the coach on a private lesson", async () => {
201+
selectLimit.mockResolvedValue([
202+
{
203+
id: SERVICE_ID,
204+
type: "private_lessons",
205+
status: "active",
206+
stripeProductId: "prod_1",
207+
},
208+
]);
209+
210+
const result = await updateService(
211+
null,
212+
fd({ service_id: SERVICE_ID, coach_id: "" }),
213+
);
214+
215+
expect(result?.errors?.coach_id).toBeDefined();
216+
expect(updateSet).not.toHaveBeenCalled();
217+
});
218+
});

__tests__/users-actions.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { deleteUserAdmin } from "@/app/(authenticated)/users/actions";
5+
6+
// db mock
7+
const selectLimit = jest.fn();
8+
const selectWhere = jest.fn(() => ({ limit: selectLimit }));
9+
const selectFrom = jest.fn(() => ({ where: selectWhere }));
10+
const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock;
11+
12+
jest.mock("@/lib/db", () => ({
13+
db: {
14+
select: (...args: unknown[]) => select(...args),
15+
},
16+
}));
17+
18+
// supabase admin mock
19+
const deleteUser = jest.fn();
20+
jest.mock("@/utils/supabase/admin", () => ({
21+
createAdminClient: () => ({
22+
auth: { admin: { deleteUser: (...args: unknown[]) => deleteUser(...args) } },
23+
}),
24+
}));
25+
26+
// auth + cache + stripe mocks
27+
const requireAdmin = jest.fn();
28+
jest.mock("@/lib/auth/require-admin", () => ({
29+
requireAdmin: (...args: unknown[]) => requireAdmin(...args),
30+
}));
31+
32+
jest.mock("next/cache", () => ({ revalidatePath: jest.fn() }));
33+
34+
// actions.ts imports grantComplimentarySubscription from @/lib/stripe, which
35+
// instantiates the Stripe client at module load.
36+
jest.mock("@/lib/stripe", () => ({
37+
grantComplimentarySubscription: jest.fn(),
38+
}));
39+
40+
const COACH_ID = "11111111-1111-1111-1111-111111111111";
41+
42+
function fd(obj: Record<string, string>): FormData {
43+
const f = new FormData();
44+
for (const [k, v] of Object.entries(obj)) f.append(k, v);
45+
return f;
46+
}
47+
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
requireAdmin.mockResolvedValue(undefined);
51+
selectLimit.mockResolvedValue([]);
52+
deleteUser.mockResolvedValue({ error: null });
53+
});
54+
55+
describe("deleteUserAdmin", () => {
56+
it("returns Unauthorized when the caller is not an admin", async () => {
57+
requireAdmin.mockRejectedValue(new Error("Forbidden"));
58+
59+
const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID }));
60+
61+
expect(result).toEqual({ errors: { _form: ["Unauthorized"] } });
62+
expect(deleteUser).not.toHaveBeenCalled();
63+
});
64+
65+
it("blocks deletion when the coach is assigned to a private lesson", async () => {
66+
selectLimit.mockResolvedValue([{ id: "svc-1" }]);
67+
68+
const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID }));
69+
70+
expect(result?.errors?._form?.[0]).toMatch(/private lesson/i);
71+
expect(deleteUser).not.toHaveBeenCalled();
72+
});
73+
74+
it("deletes the user when not assigned to any private lesson", async () => {
75+
selectLimit.mockResolvedValue([]);
76+
77+
const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID }));
78+
79+
expect(result).toEqual({ message: "User deleted." });
80+
expect(deleteUser).toHaveBeenCalledWith(COACH_ID);
81+
});
82+
83+
it("surfaces an auth error from Supabase", async () => {
84+
deleteUser.mockResolvedValue({ error: { message: "boom" } });
85+
86+
const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID }));
87+
88+
expect(result).toEqual({ errors: { _form: ["boom"] } });
89+
});
90+
});

app/(authenticated)/services/actions.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createPrice,
1212
createProduct,
1313
deactivateActivePricesForProduct,
14+
getStripeServiceData,
1415
updateProduct,
1516
} from "@/lib/stripe";
1617

@@ -119,7 +120,11 @@ function parseProgramSchedule(formData: FormData): ParseResult<ProgramSchedule>
119120

120121
function parseCoachId(formData: FormData): ParseResult<string> {
121122
const raw = field(formData, "coach_id");
122-
if (!raw) return { ok: false, errors: { coach_id: ["Select a coach"] } };
123+
if (!raw)
124+
return {
125+
ok: false,
126+
errors: { coach_id: ["A coach is required for private lessons"] },
127+
};
123128
const result = z.string().uuid().safeParse(raw);
124129
if (!result.success) {
125130
return { ok: false, errors: { coach_id: ["Invalid coach"] } };
@@ -310,6 +315,8 @@ export async function updateService(
310315
scheduledAtValue = result.value;
311316
}
312317
} else if (row.type === "private_lessons" && formData.has("coach_id")) {
318+
// Private lessons can be reassigned to a different coach, but the coach
319+
// remains mandatory: an empty/invalid value is rejected.
313320
const coach = parseCoachId(formData);
314321
if (!coach.ok) Object.assign(errors, coach.errors);
315322
else coachIdValue = coach.value;
@@ -329,14 +336,21 @@ export async function updateService(
329336
});
330337

331338
if (cents !== undefined) {
332-
// Stripe Prices are immutable: deactivate the current active
333-
// price(s) and create a new one at the new amount.
334-
await deactivateActivePricesForProduct(row.stripeProductId);
335-
await createPrice(row.stripeProductId, cents);
339+
// The edit form always submits the price, so only swap it when the
340+
// amount actually changed. Recreating an unchanged price needlessly
341+
// archives the product's default price, which Stripe rejects.
342+
const current = await getStripeServiceData(row.stripeProductId);
343+
if (current?.priceCents !== cents) {
344+
// Stripe Prices are immutable: deactivate the current active
345+
// price(s) and create a new one at the new amount.
346+
await deactivateActivePricesForProduct(row.stripeProductId);
347+
await createPrice(row.stripeProductId, cents);
348+
}
336349
}
337350

338351
const dbPatch: Partial<typeof services.$inferInsert> = {};
339352
if (duration_minutes !== undefined) dbPatch.durationMinutes = duration_minutes;
353+
if (coachIdValue !== undefined) dbPatch.coachId = coachIdValue;
340354
if (scheduledAtValue !== undefined) {
341355
dbPatch.startDate = scheduledAtValue.startDate;
342356
dbPatch.endDate = scheduledAtValue.endDate;

0 commit comments

Comments
 (0)