Skip to content
Open
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
20 changes: 20 additions & 0 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
17 changes: 16 additions & 1 deletion app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
} from '@/types';
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';

Check warning on line 54 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format Β· Lint Β· Typecheck Β· Test

'escapeXML' is defined but never used. Allowed unused vars must match /^_/u
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
Expand Down Expand Up @@ -101,6 +101,21 @@
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,
Expand Down Expand Up @@ -1035,7 +1050,7 @@
}

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';
Expand Down
15 changes: 12 additions & 3 deletions lib/validations.streakParamsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
13 changes: 12 additions & 1 deletion lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
Loading