Skip to content
Open
Show file tree
Hide file tree
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
162 changes: 117 additions & 45 deletions src/app/api/affiliates/offers/[id]/applications/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ vi.mock("@/lib/auth/get-user", () => ({
}));

const mockFrom = vi.fn();
const mockRpc = vi.fn();
vi.mock("@/lib/supabase/service", () => ({
createServiceClient: () => ({
from: (...args: unknown[]) => mockFrom(...args),
rpc: (...args: unknown[]) => mockRpc(...args),
}),
}));

Expand All @@ -29,9 +31,46 @@ function makePatchRequest(body: BodyInit, contentType = "application/json") {
);
}

function mockOwnedOffer() {
return {
select: () => ({
eq: () => ({
single: () =>
Promise.resolve({
data: { id: "offer-1", seller_id: "seller-1" },
error: null,
}),
}),
}),
};
}

function mockUpdatedApplication(status: "approved" | "rejected") {
return {
select: () => ({
eq: () => ({
eq: () => ({
single: () =>
Promise.resolve({
data: {
id: "app-1",
offer_id: "offer-1",
affiliate_id: "affiliate-1",
status,
profiles: { username: "alice" },
},
error: null,
}),
}),
}),
}),
};
}

describe("PATCH /api/affiliates/offers/[id]/applications", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRpc.mockResolvedValue({ data: null, error: null });
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});
Expand All @@ -44,6 +83,7 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => {
expect(res.status).toBe(400);
expect(body.error).toBe("Invalid request body");
expect(mockFrom).not.toHaveBeenCalled();
expect(mockRpc).not.toHaveBeenCalled();
});

it("returns 400 for non-object JSON bodies", async () => {
Expand All @@ -53,55 +93,54 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => {
expect(res.status).toBe(400);
expect(body.error).toBe("Invalid request body");
expect(mockFrom).not.toHaveBeenCalled();
expect(mockRpc).not.toHaveBeenCalled();
});

it("approves an application and sends an approval notification", async () => {
const updatedApplication = {
id: "app-1",
offer_id: "offer-1",
affiliate_id: "affiliate-1",
status: "approved",
profiles: { username: "alice" },
};
let updatePayload: Record<string, unknown> | undefined;
it("approves an application through the atomic status RPC and sends a notification", async () => {
let notificationPayload: Record<string, unknown> | undefined;

mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return {
select: () => ({
eq: () => ({
single: () =>
Promise.resolve({
data: { id: "offer-1", seller_id: "seller-1" },
error: null,
}),
}),
}),
};
}

if (table === "affiliate_applications") {
if (table === "affiliate_offers") return mockOwnedOffer();
if (table === "affiliate_applications") return mockUpdatedApplication("approved");
if (table === "notifications") {
return {
update: (payload: Record<string, unknown>) => {
updatePayload = payload;
return {
eq: () => ({
eq: () => ({
select: () => ({
single: () =>
Promise.resolve({
data: updatedApplication,
error: null,
}),
}),
}),
}),
};
insert: (payload: Record<string, unknown>) => {
notificationPayload = payload;
return Promise.resolve({ data: null, error: null });
},
};
}
throw new Error(`Unexpected table: ${table}`);
});

const res = await PATCH(
makePatchRequest(
JSON.stringify({ application_id: "app-1", action: "approve" })
),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(200);
expect(body.application).toMatchObject({ id: "app-1", status: "approved" });
expect(mockRpc).toHaveBeenCalledWith("update_affiliate_application_status", {
p_application_id: "app-1",
p_offer_id: "offer-1",
p_status: "approved",
});
expect(notificationPayload).toMatchObject({
user_id: "affiliate-1",
type: "affiliate_approved",
data: { offer_id: "offer-1", application_id: "app-1" },
});
});

it("rejects an application through the atomic status RPC and sends a notification", async () => {
let notificationPayload: Record<string, unknown> | undefined;

mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") return mockOwnedOffer();
if (table === "affiliate_applications") return mockUpdatedApplication("rejected");
if (table === "notifications") {
return {
insert: (payload: Record<string, unknown>) => {
Expand All @@ -110,26 +149,59 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => {
},
};
}

throw new Error(`Unexpected table: ${table}`);
});

const res = await PATCH(
makePatchRequest(
JSON.stringify({ application_id: "app-1", action: "approve" })
JSON.stringify({ application_id: "app-1", action: "reject" })
),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(200);
expect(body.application).toEqual(updatedApplication);
expect(updatePayload).toMatchObject({ status: "approved" });
expect(updatePayload?.approved_at).toEqual(expect.any(String));
expect(body.application).toMatchObject({ id: "app-1", status: "rejected" });
expect(mockRpc).toHaveBeenCalledWith("update_affiliate_application_status", {
p_application_id: "app-1",
p_offer_id: "offer-1",
p_status: "rejected",
});
expect(notificationPayload).toMatchObject({
user_id: "affiliate-1",
type: "affiliate_approved",
type: "affiliate_rejected",
title: "Affiliate application declined",
body: "Your affiliate application was not approved.",
data: { offer_id: "offer-1", application_id: "app-1" },
});
});

it("returns an error and does not notify when the atomic status RPC fails", async () => {
mockRpc.mockResolvedValueOnce({
data: null,
error: { message: "Application not found" },
});

mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") return mockOwnedOffer();
if (table === "notifications") {
return {
insert: vi.fn(),
};
}
throw new Error(`Unexpected table: ${table}`);
});

const res = await PATCH(
makePatchRequest(
JSON.stringify({ application_id: "app-1", action: "approve" })
),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("Application not found");
expect(mockFrom).not.toHaveBeenCalledWith("notifications");
});
});
25 changes: 15 additions & 10 deletions src/app/api/affiliates/offers/[id]/applications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,24 +102,29 @@ export async function PATCH(
}

const status = action === "approve" ? "approved" : "rejected";
const updateData: Record<string, unknown> = {
status,
updated_at: new Date().toISOString(),
};
if (status === "approved") {
updateData.approved_at = new Date().toISOString();

const { error: statusError } = await (admin as AnySupabase).rpc(
"update_affiliate_application_status",
{
p_application_id: application_id,
p_offer_id: id,
p_status: status,
}
);

if (statusError) {
return NextResponse.json({ error: statusError.message }, { status: 400 });
}

const { data: application, error } = await (admin as AnySupabase)
.from("affiliate_applications")
.update(updateData)
.select(`*, profiles!affiliate_applications_affiliate_id_fkey(username)`)
.eq("id", application_id)
.eq("offer_id", id)
.select(`*, profiles!affiliate_applications_affiliate_id_fkey(username)`)
.single();

if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
if (error || !application) {
return NextResponse.json({ error: error?.message || "Application not found" }, { status: 404 });
}

// Notify affiliate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
CREATE OR REPLACE FUNCTION public.update_affiliate_application_status(
p_application_id UUID,
p_offer_id UUID,
p_status affiliate_application_status
)
RETURNS VOID
LANGUAGE plpgsql
Comment thread
greptile-apps[bot] marked this conversation as resolved.
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
previous_status affiliate_application_status;
BEGIN
SELECT status
INTO previous_status
FROM public.affiliate_applications
WHERE id = p_application_id
AND offer_id = p_offer_id
FOR UPDATE;

IF NOT FOUND THEN
RAISE EXCEPTION 'Application not found';
END IF;

UPDATE public.affiliate_applications
SET
status = p_status,
approved_at = CASE WHEN p_status = 'approved' THEN NOW() ELSE approved_at END,
updated_at = NOW()
WHERE id = p_application_id
AND offer_id = p_offer_id;

IF p_status = 'approved' AND previous_status <> 'approved' THEN
UPDATE public.affiliate_offers
SET
total_affiliates = total_affiliates + 1,
updated_at = NOW()
WHERE id = p_offer_id;
ELSIF p_status = 'rejected' AND previous_status = 'approved' THEN
UPDATE public.affiliate_offers
SET
total_affiliates = GREATEST(total_affiliates - 1, 0),
updated_at = NOW()
WHERE id = p_offer_id;
END IF;
END;
$$;

REVOKE ALL ON FUNCTION public.update_affiliate_application_status(
UUID,
UUID,
affiliate_application_status
) FROM PUBLIC;

GRANT EXECUTE ON FUNCTION public.update_affiliate_application_status(
UUID,
UUID,
affiliate_application_status
) TO service_role;