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
117 changes: 116 additions & 1 deletion src/app/api/affiliates/offers/[id]/conversions/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "./route";
import { DELETE, GET, POST, PUT } from "./route";
import { NextRequest } from "next/server";

// Mock auth
Expand Down Expand Up @@ -39,6 +39,28 @@ function makePostRequest(id: string, body: Record<string, unknown>) {
);
}

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

function makeRawRequest(id: string, method: "PUT" | "DELETE", body: string) {
return new NextRequest(
`http://localhost/api/affiliates/offers/${id}/conversions`,
{
method,
headers: { "Content-Type": "application/json" },
body,
}
);
}

function makeParams(id: string) {
return { params: Promise.resolve({ id }) };
}
Expand Down Expand Up @@ -321,4 +343,97 @@ describe("POST /api/affiliates/offers/[id]/conversions", () => {
const body2 = await res2.json();
expect(body2.error).toBe("sale_amount_sats must be a positive number");
});

it("returns 400 for malformed JSON request bodies", 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 POST(
makeRawPostRequest("offer-1", "{not valid json"),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("Invalid request body");
expect(mockRecordConversion).not.toHaveBeenCalled();
});
});

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

it("returns 400 for malformed JSON request bodies", 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,
});
}
throw new Error(`Unexpected table query: ${table}`);
});

const res = await PUT(
makeRawRequest("offer-1", "PUT", "{not valid json"),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("Invalid request body");
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
});
});

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

it("returns 400 for malformed JSON request bodies", 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",
});
}
throw new Error(`Unexpected table query: ${table}`);
});

const res = await DELETE(
makeRawRequest("offer-1", "DELETE", "{not valid json"),
makeParams("offer-1")
);
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("Invalid request body");
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
});
});
33 changes: 27 additions & 6 deletions src/app/api/affiliates/offers/[id]/conversions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createServiceClient } from "@/lib/supabase/service";
import { recordConversion } from "@/lib/affiliates/commission";
import { safeParseBody } from "@/lib/sanitize";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySupabase = any;
Expand Down Expand Up @@ -131,7 +132,15 @@ export async function POST(
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}

const body = await request.json();
const body = await safeParseBody<{
affiliate_id?: unknown;
sale_amount_sats?: unknown;
note?: unknown;
}>(request);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { affiliate_id, sale_amount_sats, note } = body;

if (!affiliate_id || typeof affiliate_id !== "string") {
Expand Down Expand Up @@ -177,8 +186,9 @@ export async function POST(

// Update source and note on the created conversion
const updateData: Record<string, unknown> = { source: "manual" };
if (note && typeof note === "string") {
updateData.note = note.trim();
const noteText = typeof note === "string" ? note.trim() : null;
if (noteText) {
updateData.note = noteText;
}
Comment on lines +189 to 192

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 When note is an empty string, noteText evaluates to "" (falsy), so the database update is correctly skipped — but the response still returns note: "" rather than note: null. Before this change, the response used note?.trim() || null, which coerced empty-string back to null. The new approach creates a subtle inconsistency between what the response claims and what is actually stored in the database.

Suggested change
const noteText = typeof note === "string" ? note.trim() : null;
if (noteText) {
updateData.note = noteText;
}
const noteText = typeof note === "string" && note.trim() ? note.trim() : null;
if (noteText) {
updateData.note = noteText;
}


await (admin as AnySupabase)
Expand All @@ -192,7 +202,7 @@ export async function POST(
commission_sats: result.commission_sats,
settles_at: result.settles_at,
source: "manual",
note: note?.trim() || null,
note: noteText,
},
});
} catch {
Expand Down Expand Up @@ -230,7 +240,15 @@ export async function PUT(
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}

const body = await request.json();
const body = await safeParseBody<{
conversion_id?: unknown;
sale_amount_sats?: unknown;
note?: unknown;
status?: unknown;
}>(request);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { conversion_id, sale_amount_sats, note, status } = body;

if (!conversion_id) {
Expand Down Expand Up @@ -295,7 +313,10 @@ export async function DELETE(
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}

const body = await request.json();
const body = await safeParseBody<{ conversion_id?: unknown }>(request);
if (!body) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { conversion_id } = body;

if (!conversion_id) {
Expand Down