Skip to content

Commit 65a78d0

Browse files
committed
fix: add Zod validation and tests for discover endpoint
- Created validateDiscoverQuery.ts with Zod schema for country (2-letter), genre, sort, limit (1-100), and sp_monthly_listeners min/max - Refactored getResearchDiscoverHandler to use validated params - Added 6 tests: auth failure, invalid country, negative limit, happy path, sp_ml range, and proxy failure Made-with: Cursor
1 parent 52228d2 commit 65a78d0

3 files changed

Lines changed: 208 additions & 18 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler";
5+
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
6+
import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric";
7+
8+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
9+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
10+
}));
11+
12+
vi.mock("@/lib/auth/validateAuthContext", () => ({
13+
validateAuthContext: vi.fn(),
14+
}));
15+
16+
vi.mock("@/lib/research/proxyToChartmetric", () => ({
17+
proxyToChartmetric: vi.fn(),
18+
}));
19+
20+
vi.mock("@/lib/credits/deductCredits", () => ({
21+
deductCredits: vi.fn(),
22+
}));
23+
24+
describe("getResearchDiscoverHandler", () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
});
28+
29+
it("returns 401 when auth fails", async () => {
30+
const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
31+
vi.mocked(validateAuthContext).mockResolvedValue(errorResponse);
32+
33+
const req = new NextRequest("http://localhost/api/research/discover?country=US");
34+
const res = await getResearchDiscoverHandler(req);
35+
expect(res.status).toBe(401);
36+
});
37+
38+
it("returns 400 when country is not 2 letters", async () => {
39+
vi.mocked(validateAuthContext).mockResolvedValue({
40+
accountId: "test-id",
41+
orgId: null,
42+
authToken: "tok",
43+
} as ReturnType<typeof validateAuthContext> extends Promise<infer T>
44+
? Exclude<T, NextResponse>
45+
: never);
46+
47+
const req = new NextRequest("http://localhost/api/research/discover?country=USA");
48+
const res = await getResearchDiscoverHandler(req);
49+
expect(res.status).toBe(400);
50+
const body = await res.json();
51+
expect(body.status).toBe("error");
52+
expect(body.error).toContain("2-letter");
53+
});
54+
55+
it("returns 400 when limit is negative", async () => {
56+
vi.mocked(validateAuthContext).mockResolvedValue({
57+
accountId: "test-id",
58+
orgId: null,
59+
authToken: "tok",
60+
} as ReturnType<typeof validateAuthContext> extends Promise<infer T>
61+
? Exclude<T, NextResponse>
62+
: never);
63+
64+
const req = new NextRequest("http://localhost/api/research/discover?limit=-5");
65+
const res = await getResearchDiscoverHandler(req);
66+
expect(res.status).toBe(400);
67+
});
68+
69+
it("returns artists on success", async () => {
70+
vi.mocked(validateAuthContext).mockResolvedValue({
71+
accountId: "test-id",
72+
orgId: null,
73+
authToken: "tok",
74+
} as ReturnType<typeof validateAuthContext> extends Promise<infer T>
75+
? Exclude<T, NextResponse>
76+
: never);
77+
78+
vi.mocked(proxyToChartmetric).mockResolvedValue({
79+
data: [
80+
{ name: "Artist A", sp_monthly_listeners: 100000 },
81+
{ name: "Artist B", sp_monthly_listeners: 200000 },
82+
],
83+
status: 200,
84+
});
85+
86+
const req = new NextRequest("http://localhost/api/research/discover?country=US&limit=10");
87+
const res = await getResearchDiscoverHandler(req);
88+
expect(res.status).toBe(200);
89+
const body = await res.json();
90+
expect(body.status).toBe("success");
91+
expect(body.artists).toHaveLength(2);
92+
expect(body.artists[0].name).toBe("Artist A");
93+
});
94+
95+
it("passes sp_ml range when both min and max provided", async () => {
96+
vi.mocked(validateAuthContext).mockResolvedValue({
97+
accountId: "test-id",
98+
orgId: null,
99+
authToken: "tok",
100+
} as ReturnType<typeof validateAuthContext> extends Promise<infer T>
101+
? Exclude<T, NextResponse>
102+
: never);
103+
104+
vi.mocked(proxyToChartmetric).mockResolvedValue({
105+
data: [],
106+
status: 200,
107+
});
108+
109+
const req = new NextRequest(
110+
"http://localhost/api/research/discover?sp_monthly_listeners_min=50000&sp_monthly_listeners_max=200000",
111+
);
112+
await getResearchDiscoverHandler(req);
113+
114+
expect(proxyToChartmetric).toHaveBeenCalledWith(
115+
"/artist/list/filter",
116+
expect.objectContaining({ "sp_ml[]": "50000,200000" }),
117+
);
118+
});
119+
120+
it("returns empty array when proxy fails", async () => {
121+
vi.mocked(validateAuthContext).mockResolvedValue({
122+
accountId: "test-id",
123+
orgId: null,
124+
authToken: "tok",
125+
} as ReturnType<typeof validateAuthContext> extends Promise<infer T>
126+
? Exclude<T, NextResponse>
127+
: never);
128+
129+
vi.mocked(proxyToChartmetric).mockResolvedValue({
130+
data: null,
131+
status: 500,
132+
});
133+
134+
const req = new NextRequest("http://localhost/api/research/discover?country=US");
135+
const res = await getResearchDiscoverHandler(req);
136+
expect(res.status).toBe(500);
137+
});
138+
});

lib/research/getResearchDiscoverHandler.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { type NextRequest } from "next/server";
1+
import { type NextRequest, NextResponse } from "next/server";
22
import { handleResearchRequest } from "@/lib/research/handleResearchRequest";
3+
import { validateDiscoverQuery } from "@/lib/research/validateDiscoverQuery";
4+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
35

46
/**
57
* Discover handler — filters artists by country, genre, listener ranges, growth rate.
@@ -8,27 +10,30 @@ import { handleResearchRequest } from "@/lib/research/handleResearchRequest";
810
* @returns JSON artist list or error
911
*/
1012
export async function getResearchDiscoverHandler(request: NextRequest) {
13+
const { searchParams } = new URL(request.url);
14+
const validated = validateDiscoverQuery(searchParams);
15+
16+
if (validated instanceof NextResponse) return validated;
17+
1118
return handleResearchRequest(
1219
request,
1320
() => "/artist/list/filter",
14-
sp => {
21+
() => {
1522
const params: Record<string, string> = {};
16-
const country = sp.get("country");
17-
if (country) params.code2 = country;
18-
const genre = sp.get("genre");
19-
if (genre) params.tagId = genre;
20-
const sort = sp.get("sort");
21-
if (sort) params.sortColumn = sort;
22-
const limit = sp.get("limit");
23-
if (limit) params.limit = limit;
24-
const mlMin = sp.get("sp_monthly_listeners_min");
25-
const mlMax = sp.get("sp_monthly_listeners_max");
26-
if (mlMin && mlMax) {
27-
params["sp_ml[]"] = `${mlMin},${mlMax}`;
28-
} else if (mlMin) {
29-
params["sp_ml[]"] = mlMin;
30-
} else if (mlMax) {
31-
params["sp_ml[]"] = mlMax;
23+
if (validated.country) params.code2 = validated.country;
24+
if (validated.genre) params.tagId = validated.genre;
25+
if (validated.sort) params.sortColumn = validated.sort;
26+
if (validated.limit) params.limit = String(validated.limit);
27+
if (
28+
validated.sp_monthly_listeners_min !== undefined &&
29+
validated.sp_monthly_listeners_max !== undefined
30+
) {
31+
params["sp_ml[]"] =
32+
`${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`;
33+
} else if (validated.sp_monthly_listeners_min !== undefined) {
34+
params["sp_ml[]"] = String(validated.sp_monthly_listeners_min);
35+
} else if (validated.sp_monthly_listeners_max !== undefined) {
36+
params["sp_ml[]"] = String(validated.sp_monthly_listeners_max);
3237
}
3338
return params;
3439
},
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { z } from "zod";
4+
5+
export const discoverQuerySchema = z.object({
6+
country: z.string().length(2, "country must be a 2-letter ISO code").optional(),
7+
genre: z.string().optional(),
8+
sort: z.string().optional(),
9+
limit: z.coerce.number().int().min(1).max(100).optional(),
10+
sp_monthly_listeners_min: z.coerce.number().int().min(0).optional(),
11+
sp_monthly_listeners_max: z.coerce.number().int().min(0).optional(),
12+
});
13+
14+
export type DiscoverQuery = z.infer<typeof discoverQuerySchema>;
15+
16+
/**
17+
* Validates query params for GET /api/research/discover.
18+
*/
19+
export function validateDiscoverQuery(searchParams: URLSearchParams): NextResponse | DiscoverQuery {
20+
const raw: Record<string, string> = {};
21+
for (const key of [
22+
"country",
23+
"genre",
24+
"sort",
25+
"limit",
26+
"sp_monthly_listeners_min",
27+
"sp_monthly_listeners_max",
28+
]) {
29+
const val = searchParams.get(key);
30+
if (val) raw[key] = val;
31+
}
32+
33+
const result = discoverQuerySchema.safeParse(raw);
34+
35+
if (!result.success) {
36+
const firstError = result.error.issues[0];
37+
return NextResponse.json(
38+
{
39+
status: "error",
40+
error: firstError.message,
41+
},
42+
{ status: 400, headers: getCorsHeaders() },
43+
);
44+
}
45+
46+
return result.data;
47+
}

0 commit comments

Comments
 (0)