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
75 changes: 70 additions & 5 deletions src/app/api/affiliates/offers/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ vi.mock("@/lib/supabase/service", () => ({
}),
}));

vi.mock("@/lib/affiliates/validation", () => ({
validateOfferInput: vi.fn(),
}));

import { GET } from "./route";
import { GET, PATCH } from "./route";

function makeRequest(id: string) {
return new NextRequest(`http://localhost/api/affiliates/offers/${id}`);
Expand Down Expand Up @@ -113,3 +109,72 @@ describe("GET /api/affiliates/offers/[id]", () => {
expect(res.status).toBe(404);
});
});

describe("PATCH /api/affiliates/offers/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAuthContext.mockResolvedValue({
user: { id: "seller1", authMethod: "session" },
});
});

it("rejects non-http product_url updates", async () => {
mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({ id: "offer-1", seller_id: "seller1" });
}
return chainable([]);
});

const req = new NextRequest("http://localhost/api/affiliates/offers/offer-1", {
method: "PATCH",
body: JSON.stringify({ product_url: "javascript:alert(1)" }),
});

const res = await PATCH(req, makeParams("offer-1"));
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("product_url must use http:// or https:// scheme");
});

it("trims valid product_url updates before saving", async () => {
mockFrom.mockImplementation((table: string) => {
if (table !== "affiliate_offers") return chainable([]);

const update = vi.fn((data: Record<string, unknown>) => ({
eq: vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn(() => Promise.resolve({
data: { id: "offer-1", seller_id: "seller1", ...data },
error: null,
})),
})),
})),
}));

return {
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(() => Promise.resolve({
data: { id: "offer-1", seller_id: "seller1" },
error: null,
})),
})),
})),
update,
};
});

const req = new NextRequest("http://localhost/api/affiliates/offers/offer-1", {
method: "PATCH",
body: JSON.stringify({ product_url: " https://example.com/product " }),
});

const res = await PATCH(req, makeParams("offer-1"));
const body = await res.json();

expect(res.status).toBe(200);
expect(body.offer.product_url).toBe("https://example.com/product");
});
Comment on lines +113 to +179

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 Missing tests for the null / blank-string clearing paths

The PR description calls out "allow explicit clearing with null or blank strings" as a key behavior, but neither branch is exercised. Sending { product_url: null } should persist null, and sending { product_url: " " } should also clear it — both paths exist in the handler (lines 104–111) but have no regression test. If a future refactor accidentally drops the === null guard or the length === 0 branch, nothing will catch it.

});
24 changes: 20 additions & 4 deletions src/app/api/affiliates/offers/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createServiceClient } from "@/lib/supabase/service";
import { isValidUrl } from "@/lib/affiliates/validation";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySupabase = any;
import { validateOfferInput } from "@/lib/affiliates/validation";


/**
* GET /api/affiliates/offers/[id] - Get offer details
Expand Down Expand Up @@ -102,7 +100,25 @@ export async function PATCH(

if (body.title !== undefined) updateData.title = body.title.trim();
if (body.description !== undefined) updateData.description = body.description.trim();
if (body.product_url !== undefined) updateData.product_url = body.product_url;
if (body.product_url !== undefined) {
if (body.product_url === null) {
updateData.product_url = null;
} else if (typeof body.product_url !== "string") {
return NextResponse.json({ error: "product_url must be a string" }, { status: 400 });
} else {
const productUrl = body.product_url.trim();
if (productUrl.length === 0) {
updateData.product_url = null;
} else if (!isValidUrl(productUrl)) {
return NextResponse.json(
{ error: "product_url must use http:// or https:// scheme" },
{ status: 400 }
);
} else {
updateData.product_url = productUrl;
}
}
}
if (body.product_type !== undefined) updateData.product_type = body.product_type;
if (body.price_sats !== undefined) updateData.price_sats = body.price_sats;
if (body.commission_rate !== undefined) updateData.commission_rate = body.commission_rate;
Expand Down
Loading