diff --git a/src/app/api/applications/route.test.ts b/src/app/api/applications/route.test.ts new file mode 100644 index 00000000..792ceeae --- /dev/null +++ b/src/app/api/applications/route.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +// ── Mocks ────────────────────────────────────────────────────────── + +const mockFrom = vi.fn(); + +const supabaseClient = { + from: mockFrom, +}; + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), + createServiceClient: vi.fn(() => ({ + auth: { admin: { getUserById: vi.fn().mockResolvedValue({ data: null }) } }, + })), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(() => ({ allowed: true })), + rateLimitExceeded: vi.fn(), + getRateLimitIdentifier: vi.fn(() => "test"), +})); + +vi.mock("@/lib/reputation-hooks", () => ({ + getUserDid: vi.fn().mockResolvedValue(null), + onApplicationSubmitted: vi.fn(), +})); + +vi.mock("@/lib/email", () => ({ + sendEmail: vi.fn().mockResolvedValue(undefined), + newApplicationEmail: vi.fn(() => ({ subject: "test", text: "test", html: "test" })), +})); + +vi.mock("@/lib/webhooks/dispatch", () => ({ + dispatchWebhookAsync: vi.fn(), +})); + +vi.mock("@/lib/activity", () => ({ + logActivity: vi.fn().mockResolvedValue(undefined), +})); + +import { getAuthContext } from "@/lib/auth/get-user"; +const mockGetAuthContext = vi.mocked(getAuthContext); + +type MockAuthContext = any; + +const GIG_ID = "00000000-0000-4000-a000-000000000001"; + +function makeRequest(body: Record) { + return new NextRequest("http://localhost/api/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function mockChain() { + const chain: Record = {}; + for (const m of ["select", "update", "insert", "eq", "single", "contains", "order"]) { + chain[m] = vi.fn().mockReturnValue(chain); + } + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Tests ────────────────────────────────────────────────────────── + +describe("POST /api/applications - re-apply after withdrawal", () => { + it("re-activates a withdrawn application instead of blocking (regression)", async () => { + // Regression for the ugig bug where a withdrawn application still counted + // as "already applied", so the applicant could never re-apply and was + // therefore blocked from ever invoicing merged work. + const insertCalls: unknown[] = []; + const updateCalls: Record[] = []; + const applicationsCalls: string[] = []; + + mockFrom.mockImplementation((table: string) => { + const chain = mockChain(); + + if (table === "gigs") { + (chain.single as ReturnType).mockResolvedValue({ + data: { + poster_id: "poster-1", + status: "active", + title: "Test Gig", + poster: { full_name: "Poster" }, + }, + error: null, + }); + } else if (table === "applications") { + applicationsCalls.push(table); + if (applicationsCalls.length === 1) { + // Existing-application lookup: a WITHDRAWN application exists. + (chain.single as ReturnType).mockResolvedValue({ + data: { id: "app-existing", status: "withdrawn" }, + error: null, + }); + } else { + // Re-activation path: must UPDATE, never INSERT. + (chain.update as ReturnType).mockImplementation( + (payload: Record) => { + updateCalls.push(payload); + return chain; + } + ); + (chain.insert as ReturnType).mockImplementation((payload: unknown) => { + insertCalls.push(payload); + return chain; + }); + (chain.single as ReturnType).mockResolvedValue({ + data: { id: "app-existing", gig_id: GIG_ID, applicant_id: "user-1", status: "pending" }, + error: null, + }); + } + } else if (table === "profiles") { + (chain.single as ReturnType).mockResolvedValue({ + data: { full_name: "Applicant", username: "applicant" }, + error: null, + }); + } + return chain; + }); + + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-1", authMethod: "api_key" }, + supabase: supabaseClient, + } as MockAuthContext); + + const req = makeRequest({ gig_id: GIG_ID, cover_letter: "x".repeat(60) }); + const res = await POST(req); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.application.id).toBe("app-existing"); + // The withdrawn row is reused (updated back to pending), not duplicated. + expect(insertCalls).toHaveLength(0); + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0].status).toBe("pending"); + }); + + it("still blocks re-applying when an active (pending) application exists", async () => { + mockFrom.mockImplementation((table: string) => { + const chain = mockChain(); + if (table === "gigs") { + (chain.single as ReturnType).mockResolvedValue({ + data: { + poster_id: "poster-1", + status: "active", + title: "Test Gig", + poster: { full_name: "Poster" }, + }, + error: null, + }); + } else if (table === "applications") { + (chain.single as ReturnType).mockResolvedValue({ + data: { id: "app-existing", status: "pending" }, + error: null, + }); + } + return chain; + }); + + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-1", authMethod: "api_key" }, + supabase: supabaseClient, + } as MockAuthContext); + + const req = makeRequest({ gig_id: GIG_ID, cover_letter: "x".repeat(60) }); + const res = await POST(req); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("already applied"); + }); +}); diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index 51f0dcc4..e4bca073 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -73,28 +73,43 @@ export async function POST(request: NextRequest) { // Check if already applied const { data: existingApplication } = await supabase .from("applications") - .select("id") + .select("id, status") .eq("gig_id", gig_id) .eq("applicant_id", user.id) .single(); - if (existingApplication) { + // A withdrawn application may be resubmitted. Any other existing status + // (pending/reviewing/shortlisted/accepted/rejected) still blocks re-applying. + if (existingApplication && existingApplication.status !== "withdrawn") { return NextResponse.json( { error: "You have already applied to this gig" }, { status: 400 } ); } - // Create application - const { data: application, error } = await supabase - .from("applications") - .insert({ - gig_id, - applicant_id: user.id, - ...applicationData, - }) - .select() - .single(); + // Create the application, or re-activate a withdrawn one. The + // UNIQUE(gig_id, applicant_id) constraint means a prior withdrawn row must + // be updated in place rather than inserted a second time. + const { data: application, error } = existingApplication + ? await supabase + .from("applications") + .update({ + ...applicationData, + status: "pending", + updated_at: new Date().toISOString(), + }) + .eq("id", existingApplication.id) + .select() + .single() + : await supabase + .from("applications") + .insert({ + gig_id, + applicant_id: user.id, + ...applicationData, + }) + .select() + .single(); if (error) { return NextResponse.json({ error: error.message }, { status: 400 });