diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts index 50b52a72..53132b74 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GET, POST, PUT } from "./route"; +import { DELETE, GET, POST, PUT } from "./route"; import { NextRequest } from "next/server"; // Mock auth @@ -44,6 +44,14 @@ function makePutRequest(id: string, body: Record) { }); } +function makeDeleteRequest(id: string, body: Record) { + return new NextRequest(`http://localhost/api/affiliates/offers/${id}/conversions`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + function makeParams(id: string) { return { params: Promise.resolve({ id }) }; } @@ -390,6 +398,37 @@ describe("PUT /api/affiliates/offers/[id]/conversions", () => { expect(mockCalculateCommission).not.toHaveBeenCalled(); }); + it("rejects non-string conversion_id before updating conversions", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-seller", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ + id: "offer-1", + seller_id: "user-seller", + commission_rate: 0.2, + commission_type: "percentage", + commission_flat_sats: 0, + }); + } + return chainable([]); + }); + + const res = await PUT( + makePutRequest("offer-1", { + conversion_id: { id: "conv-1" }, + note: "paid elsewhere", + }), + makeParams("offer-1") + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "conversion_id is required" }); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + }); + it("updates integer sale_amount_sats and recalculates commission", async () => { mockGetAuthContext.mockResolvedValue({ user: { id: "user-seller", authMethod: "session" }, @@ -477,6 +516,39 @@ describe("PUT /api/affiliates/offers/[id]/conversions", () => { }); }); +describe("DELETE /api/affiliates/offers/[id]/conversions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects non-string conversion_id before querying conversions", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-seller", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ + id: "offer-1", + seller_id: "user-seller", + }); + } + return chainable([]); + }); + + const res = await DELETE( + makeDeleteRequest("offer-1", { + conversion_id: ["conv-1"], + }), + makeParams("offer-1") + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "conversion_id is required" }); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + }); +}); + describe("PUT /api/affiliates/offers/[id]/conversions", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.ts b/src/app/api/affiliates/offers/[id]/conversions/route.ts index b4d2696b..55eb780c 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/route.ts @@ -9,6 +9,10 @@ function isPositiveIntegerSats(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + /** * GET /api/affiliates/offers/[id]/conversions - List conversions for an offer (seller only) */ @@ -226,9 +230,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const body = await request.json(); const { conversion_id, sale_amount_sats, note, status } = body; - if (!conversion_id) { + if (!isNonEmptyString(conversion_id)) { return NextResponse.json({ error: "conversion_id is required" }, { status: 400 }); } + const conversionId = conversion_id.trim(); const updateData: Record = {}; if (typeof note === "string") updateData.note = note.trim(); @@ -261,7 +266,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const { error: updateErr } = await (admin as AnySupabase) .from("affiliate_conversions") .update(updateData) - .eq("id", conversion_id) + .eq("id", conversionId) .eq("offer_id", id); if (updateErr) { @@ -304,14 +309,15 @@ export async function DELETE( const body = await request.json(); const { conversion_id } = body; - if (!conversion_id) { + if (!isNonEmptyString(conversion_id)) { return NextResponse.json({ error: "conversion_id is required" }, { status: 400 }); } + const conversionId = conversion_id.trim(); const { data: conv } = await (admin as AnySupabase) .from("affiliate_conversions") .select("id, status") - .eq("id", conversion_id) + .eq("id", conversionId) .eq("offer_id", id) .single(); @@ -326,7 +332,7 @@ export async function DELETE( const { error: delErr } = await (admin as AnySupabase) .from("affiliate_conversions") .delete() - .eq("id", conversion_id) + .eq("id", conversionId) .eq("offer_id", id); if (delErr) {