Skip to content
Merged
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
6 changes: 3 additions & 3 deletions app/api/streak/route.mouse-interactivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ describe('ApiStreakRoute Tests', () => {
const response = await GET(request);

expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toBe('Invalid parameters');
expect(json.details.fieldErrors).toHaveProperty('user');
const body = await response.text();
expect(body).toContain('<svg');
expect(response.headers.get('Content-Type')).toContain('image/svg+xml');
});

it('returns JSON data when format is set to json', async () => {
Expand Down
167 changes: 77 additions & 90 deletions app/api/streak/route.test.ts

Large diffs are not rendered by default.

40 changes: 28 additions & 12 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ function escapeSVGText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function buildInlineErrorSVG(text: string): string {
const MAX_LINE = 48;
const truncated = text.length > MAX_LINE * 2 ? text.slice(0, MAX_LINE * 2 - 1) + '…' : text;
const line1 = escapeSVGText(truncated.slice(0, MAX_LINE));
const line2 = truncated.length > MAX_LINE ? escapeSVGText(truncated.slice(MAX_LINE)) : null;
const textY = line2 ? '62' : '75';
return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
<rect width="400" height="150" fill="#2d0000" rx="8"/>
<text x="200" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
line2
? `\n <text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
: ''
}
</svg>`;
}

function getMonthlyReferenceDate(year: string | undefined, timezone: string): Date | undefined {
if (!year) return undefined;

Expand All @@ -53,19 +69,19 @@ export async function GET(request: Request) {
if (!parseResult.success) {
const fieldErrors = parseResult.error.flatten();

return NextResponse.json(
{
error: 'Invalid parameters',
details: fieldErrors,
const firstError =
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';
const errorSvg = buildInlineErrorSVG(firstError);
return new NextResponse(errorSvg, {
status: 400,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'Content-Security-Policy': SVG_CSP_HEADER,
},
{
status: 400,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
}
);
});
}

const {
Expand Down
4 changes: 2 additions & 2 deletions app/api/streak/tests/dateRange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ describe('GET /api/streak dateRange parameter', () => {
it('invalid dateRange formats return a validation error without crashing', async () => {
const res = await GET(makeRequest({ user: 'octocat', from: 'not-a-date', to: 'also-not' }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe('Invalid parameters');
const body = await res.text();
expect(body).toContain('<svg');
});

it('sets sensible Cache-Control and Content-Type headers for SVG output', async () => {
Expand Down
6 changes: 3 additions & 3 deletions app/api/streak/tests/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ describe('Streak API - layout parameter integration tests', () => {
it('should return 400 Bad Request when layout parameter is invalid', async () => {
const response = await GET(makeRequest({ user: 'octocat', layout: 'unsupported_layout_type' }));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Invalid parameters');
expect(body.details.fieldErrors.layout[0]).toContain('Invalid layout format');
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid layout format');
});

it('should fall back to default layout when layout is empty', async () => {
Expand Down
6 changes: 3 additions & 3 deletions app/api/streak/tests/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ describe('Streak API - theme parameter integration tests', () => {
it('should return 400 Bad Request when theme parameter is invalid', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'not-a-valid-theme' }));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Invalid parameters');
expect(body.details.fieldErrors.theme[0]).toContain('Invalid theme');
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid theme');
});

it('should produce different SVGs when theme is dark vs light', async () => {
Expand Down
7 changes: 3 additions & 4 deletions app/api/streak/tests/timezone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,9 @@ describe('Streak API — tz parameter', () => {
it('returns a JSON error body with fieldErrors for an invalid timezone', async () => {
const response = await GET(makeRequest({ user: 'octocat', tz: 'Not/ATimezone' }));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Invalid parameters');
expect(body.details.fieldErrors.tz).toBeDefined();
expect(body.details.fieldErrors.tz[0]).toContain('Invalid timezone');
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid timezone');
});

it('does not call the GitHub API when the timezone is invalid', async () => {
Expand Down
Loading