diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index bc3786984..728b52321 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -197,6 +197,26 @@ describe('GET /api/streak', () => { expect(response.headers.get('Content-Type')).toContain('image/svg+xml'); }); + it('returns JSON 400 when the user parameter is missing and format=json', async () => { + const response = await GET(makeRequest({ format: 'json' })); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(body.error).toContain('Missing user parameter'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + + it('returns 404 JSON for a nonexistent user when format=json', async () => { + vi.mocked(fetchGitHubContributions).mockRejectedValue( + new Error("Could not resolve to a User with the login of 'nonexistentuser'") + ); + const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' })); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + it('returns 400 when org parameter contains spaces and invalid characters', async () => { const response = await GET( makeRequest({ user: 'octocat', org: 'invalid_org_name_with_spaces' }) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 44e5e205c..cb45c205c 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -101,6 +101,21 @@ export async function GET(request: Request) { Object.values(fieldErrors.fieldErrors).flat()[0] ?? fieldErrors.formErrors[0] ?? 'Invalid parameters'; + + if (searchParams.get('format') === 'json') { + return NextResponse.json( + { error: firstError }, + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Request-ID': requestId, + }, + } + ); + } + const errTheme = resolveErrorTheme(searchParams); const errorSvg = buildInlineErrorSVG(firstError, { bg: errTheme.bg, @@ -1035,7 +1050,7 @@ function buildErrorResponse( } if (isNotFound) { - const match = message.match(/"([^"]+)"|login of '([^']+)'/); + const match = rawMessage.match(/"([^"]+)"|login of '([^']+)'/); const fallbackTarget = parseResult.success ? parseResult.data.org || parseResult.data.user : 'unknown'; diff --git a/lib/validations.streakParamsSchema.test.ts b/lib/validations.streakParamsSchema.test.ts index 75c8fb777..85d645fda 100644 --- a/lib/validations.streakParamsSchema.test.ts +++ b/lib/validations.streakParamsSchema.test.ts @@ -65,15 +65,24 @@ describe('streakParamsSchema', () => { expect(result.success).toBe(true); }); + it('accepts input with org but missing user', () => { + const result = streakParamsSchema.safeParse({ org: 'github' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.org).toBe('github'); + expect(result.data.user).toBe(''); // default empty string + } + }); + // ── Invalid / negative cases ────────────────────────────────────────────── - it('fails when user is missing', () => { + it('fails when both user and org are missing', () => { const result = streakParamsSchema.safeParse({}); expect(result.success).toBe(false); }); - it('fails when user is an empty string', () => { - const result = streakParamsSchema.safeParse({ user: '' }); + it('fails when user and org are empty strings', () => { + const result = streakParamsSchema.safeParse({ user: '', org: '' }); expect(result.success).toBe(false); }); diff --git a/lib/validations.ts b/lib/validations.ts index b1834c1d1..32734bcbb 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -233,8 +233,9 @@ const baseStreakParamsSchema = z.object({ // Required — missing user surfaces as "Missing" to match existing tests user: z .string({ error: 'Missing user parameter' }) - .min(1, { message: 'Missing user parameter' }) + .default('') .superRefine((val, ctx) => { + if (val === '') return; const users = val.split(',').map((u) => u.trim()); if (users.length === 0) { ctx.addIssue({ @@ -693,6 +694,11 @@ const baseStreakParamsSchema = z.object({ const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000; export const streakParamsSchema = baseStreakParamsSchema + .refine( + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'], @@ -1240,6 +1246,11 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema .optional() .transform((val) => val || 'rise'), }) + .refine( + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'],