diff --git a/src/app/api/affiliates/offers/[id]/apply/route.test.ts b/src/app/api/affiliates/offers/[id]/apply/route.test.ts new file mode 100644 index 00000000..48fc87d0 --- /dev/null +++ b/src/app/api/affiliates/offers/[id]/apply/route.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +const mockGetAuthContext = vi.fn(); +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), +})); + +const mockCheckRateLimit = vi.fn(); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), + getRateLimitIdentifier: () => "rate-key", + rateLimitExceeded: () => new Response("rate limited", { status: 429 }), +})); + +vi.mock("@/lib/affiliates/tracking", () => ({ + generateTrackingCode: () => "alice-test123", +})); + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: () => ({ + from: (...args: unknown[]) => mockFrom(...args), + }), +})); + +function makeRequest() { + return new NextRequest("http://localhost/api/affiliates/offers/offer-1/apply", { + method: "POST", + body: "{}", + }); +} + +function makeParams(id = "offer-1") { + return { params: Promise.resolve({ id }) }; +} + +function makeSingleResponse(data: unknown) { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data, error: null }), + }), + single: () => Promise.resolve({ data, error: null }), + }), + }), + }; +} + +describe("POST /api/affiliates/offers/[id]/apply", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCheckRateLimit.mockReturnValue({ allowed: true }); + mockGetAuthContext.mockResolvedValue({ + user: { id: "affiliate-1", authMethod: "session" }, + }); + delete process.env.NEXT_PUBLIC_APP_URL; + }); + + it("returns the existing tracking URL when an approved affiliate reapplies", async () => { + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return makeSingleResponse({ + id: "offer-1", + seller_id: "seller-1", + status: "active", + slug: "test-offer", + }); + } + + if (table === "affiliate_applications") { + return makeSingleResponse({ + id: "application-1", + status: "approved", + tracking_code: "alice-test123", + }); + } + + return {}; + }); + + const res = await POST(makeRequest(), makeParams()); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual({ + error: "Already approved", + application: { + id: "application-1", + status: "approved", + tracking_code: "alice-test123", + }, + tracking_code: "alice-test123", + tracking_url: "https://ugig.net/ref/alice-test123", + }); + }); +}); diff --git a/src/app/api/affiliates/offers/[id]/apply/route.ts b/src/app/api/affiliates/offers/[id]/apply/route.ts index ddfdc71b..2ced42a0 100644 --- a/src/app/api/affiliates/offers/[id]/apply/route.ts +++ b/src/app/api/affiliates/offers/[id]/apply/route.ts @@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; +import { generateTrackingCode } from "@/lib/affiliates/tracking"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySupabase = any; -import { generateTrackingCode } from "@/lib/affiliates/tracking"; +function trackingUrl(trackingCode: string): string { + return `${process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"}/ref/${trackingCode}`; +} /** * POST /api/affiliates/offers/[id]/apply - Apply to become an affiliate for an offer @@ -46,7 +48,7 @@ export async function POST( // Check if already applied const { data: existing } = await (admin as AnySupabase) .from("affiliate_applications") - .select("id, status") + .select("id, status, tracking_code") .eq("offer_id", id) .eq("affiliate_id", auth.user.id) .single(); @@ -55,6 +57,8 @@ export async function POST( return NextResponse.json({ error: `Already ${existing.status}`, application: existing, + tracking_code: existing.tracking_code, + tracking_url: existing.tracking_code ? trackingUrl(existing.tracking_code) : null, }, { status: 409 }); } @@ -127,7 +131,7 @@ export async function POST( return NextResponse.json({ application, tracking_code: trackingCode, - tracking_url: `${process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"}/ref/${trackingCode}`, + tracking_url: trackingUrl(trackingCode), }, { status: 201 }); } catch { return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 });