Skip to content
Closed
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
144 changes: 144 additions & 0 deletions src/app/api/affiliates/offers/[id]/applications/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PATCH } from "./route";
import { NextRequest } from "next/server";

const mockGetAuthContext = vi.fn();
vi.mock("@/lib/auth/get-user", () => ({
getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args),
}));

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

function makePatchRequest(id: string, body: Record<string, unknown>) {
return new NextRequest(`http://localhost/api/affiliates/offers/${id}/applications`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

function makeRawPatchRequest(id: string, body: string) {
return new NextRequest(`http://localhost/api/affiliates/offers/${id}/applications`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body,
});
}

function makeParams(id: string) {
return { params: Promise.resolve({ id }) };
}

function chainable(data: unknown, error: unknown = null) {
const result = { data, error };
const handler: ProxyHandler<Record<string, unknown>> = {
get(_target, prop) {
if (prop === "then") return undefined;
if (prop === "data") return data;
if (prop === "error") return error;
return () => new Proxy(result, handler);
},
};
return new Proxy(result, handler);
}

describe("PATCH /api/affiliates/offers/[id]/applications", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("rejects malformed JSON before updating the application", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});

const res = await PATCH(
makeRawPatchRequest("offer-1", "{not valid json"),
makeParams("offer-1")
);

expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "Invalid request body" });
expect(mockFrom).not.toHaveBeenCalled();
});

it("rejects non-object JSON before updating the application", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});

const res = await PATCH(makeRawPatchRequest("offer-1", "null"), makeParams("offer-1"));

expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "Invalid request body" });
expect(mockFrom).not.toHaveBeenCalled();
});

it("still returns the updated application when notification insert fails", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});

const notificationInsert = vi.fn().mockResolvedValue({
error: { message: "notification table unavailable" },
});
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});

mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({ id: "offer-1", seller_id: "seller-1" });
}

if (table === "affiliate_applications") {
return chainable({
id: "app-1",
affiliate_id: "affiliate-1",
offer_id: "offer-1",
status: "approved",
profiles: { username: "alice" },
});
}

if (table === "notifications") {
return { insert: notificationInsert };
}

return chainable(null);
});

try {
const res = await PATCH(
makePatchRequest("offer-1", {
application_id: "app-1",
action: "approve",
}),
makeParams("offer-1")
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.application).toMatchObject({
id: "app-1",
status: "approved",
});
expect(notificationInsert).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "affiliate-1",
type: "affiliate_approved",
data: { offer_id: "offer-1", application_id: "app-1" },
})
);
expect(consoleWarn).toHaveBeenCalledWith(
"Failed to create affiliate application notification",
{ message: "notification table unavailable" }
);
} finally {
consoleWarn.mockRestore();
}
});
Comment on lines +82 to +143
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Thrown-exception path of notification catch block is untested

The new notification wrapping has two distinct failure modes: notificationError returned in the result (tested here) and an exception thrown by .insert() itself (the catch (error) branch at line 140 of route.ts). Only the first path is covered. If .insert were to throw synchronously or reject, the catch branch would fire and the route would still return 200, but that invariant is never verified. A small additional test case — where notificationInsert rejects instead of resolving with an error — would complete the coverage for this new behavior.

});
76 changes: 48 additions & 28 deletions src/app/api/affiliates/offers/[id]/applications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createServiceClient } from "@/lib/supabase/service";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySupabase = any;


/**
* GET /api/affiliates/offers/[id]/applications - List affiliates for an offer (seller only)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const auth = await getAuthContext(request);
Expand All @@ -35,10 +30,12 @@ export async function GET(

const { data: applications, error } = await (admin as AnySupabase)
.from("affiliate_applications")
.select(`
.select(
`
*,
profiles!affiliate_applications_affiliate_id_fkey(username, avatar_url)
`)
`
)
.eq("offer_id", id)
.order("created_at", { ascending: false });

Expand All @@ -55,22 +52,32 @@ export async function GET(
/**
* PATCH /api/affiliates/offers/[id]/applications - Approve/reject an application
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const auth = await getAuthContext(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await request.json();
const { application_id, action } = body;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { application_id, action } = body as Record<string, unknown>;

if (!application_id || !["approve", "reject"].includes(action)) {
return NextResponse.json({ error: "application_id and action (approve|reject) required" }, { status: 400 });
if (typeof application_id !== "string" || !["approve", "reject"].includes(action as string)) {
return NextResponse.json(
{ error: "application_id and action (approve|reject) required" },
{ status: 400 }
);
}

const admin = createServiceClient();
Expand Down Expand Up @@ -107,19 +114,32 @@ export async function PATCH(
return NextResponse.json({ error: error.message }, { status: 400 });
}

// Notify affiliate
// Notify affiliate. The status update already succeeded, so notification
// delivery should not make the API report a failed approval/rejection.
const notificationType = status === "approved" ? "affiliate_approved" : "affiliate_rejected";
await (admin as AnySupabase)
.from("notifications")
.insert({
user_id: application.affiliate_id,
type: notificationType,
title: status === "approved" ? "Affiliate application approved! 🎉" : "Affiliate application declined",
body: status === "approved"
? `You've been approved to promote this offer. Your tracking link is ready!`
: "Your affiliate application was not approved.",
data: { offer_id: id, application_id },
});
try {
const { error: notificationError } = await (admin as AnySupabase)
.from("notifications")
.insert({
user_id: application.affiliate_id,
type: notificationType,
title:
status === "approved"
? "Affiliate application approved! 🎉"
: "Affiliate application declined",
body:
status === "approved"
? `You've been approved to promote this offer. Your tracking link is ready!`
: "Your affiliate application was not approved.",
data: { offer_id: id, application_id },
});

if (notificationError) {
console.warn("Failed to create affiliate application notification", notificationError);
}
} catch (error) {
console.warn("Failed to create affiliate application notification", error);
}

return NextResponse.json({ application });
} catch {
Expand Down
Loading