Skip to content
Merged
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
180 changes: 180 additions & 0 deletions src/app/api/applications/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return new NextRequest("http://localhost/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

function mockChain() {
const chain: Record<string, unknown> = {};
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<string, unknown>[] = [];
const applicationsCalls: string[] = [];

mockFrom.mockImplementation((table: string) => {
const chain = mockChain();

if (table === "gigs") {
(chain.single as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
data: { id: "app-existing", status: "withdrawn" },
error: null,
});
} else {
// Re-activation path: must UPDATE, never INSERT.
(chain.update as ReturnType<typeof vi.fn>).mockImplementation(
(payload: Record<string, unknown>) => {
updateCalls.push(payload);
return chain;
}
);
(chain.insert as ReturnType<typeof vi.fn>).mockImplementation((payload: unknown) => {
insertCalls.push(payload);
return chain;
});
(chain.single as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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");
});
});
39 changes: 27 additions & 12 deletions src/app/api/applications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading