@@ -103,20 +103,42 @@ describe('GET /api/streak', () => {
103103 } ) ;
104104
105105 describe ( 'parameter validation' , ( ) => {
106- it ( 'falls back to default layout when an unsupported layout is provided ' , async ( ) => {
106+ it ( 'returns 400 Bad Request when ? layout= is set to an unsupported format ' , async ( ) => {
107107 const response = await GET (
108108 makeRequest ( {
109109 user : 'octocat' ,
110110 layout : 'unsupported_layout' ,
111111 } )
112112 ) ;
113113
114- expect ( response . status ) . toBe ( 200 ) ;
114+ expect ( response . status ) . toBe ( 400 ) ;
115+ } ) ;
115116
116- const body = await response . text ( ) ;
117+ it ( 'does not call the GitHub API when layout is invalid' , async ( ) => {
118+ await GET (
119+ makeRequest ( {
120+ user : 'octocat' ,
121+ layout : 'unsupported_layout' ,
122+ } )
123+ ) ;
117124
118- expect ( body ) . toContain ( '<svg' ) ;
125+ expect ( fetchGitHubContributions ) . not . toHaveBeenCalled ( ) ;
126+ } ) ;
127+
128+ it ( 'returns 400 with a structured error body for unsupported_layout' , async ( ) => {
129+ const response = await GET (
130+ makeRequest ( {
131+ user : 'octocat' ,
132+ layout : 'unsupported_layout' ,
133+ } )
134+ ) ;
135+
136+ expect ( response . status ) . toBe ( 400 ) ;
137+ const body = await response . json ( ) ;
138+ expect ( body . error ) . toBe ( 'Invalid parameters' ) ;
139+ expect ( body . details ) . not . toBeNull ( ) ;
119140 } ) ;
141+
120142 it ( 'returns 400 when the user parameter is missing' , async ( ) => {
121143 const response = await GET ( makeRequest ( ) ) ;
122144
@@ -155,6 +177,7 @@ describe('GET /api/streak', () => {
155177
156178 expect ( fetchGitHubContributions ) . not . toHaveBeenCalled ( ) ;
157179 } ) ;
180+
158181 it ( 'returns 400 when user contains spaces' , async ( ) => {
159182 const response = await GET ( makeRequest ( { user : 'john doe' } ) ) ;
160183 const body = await response . json ( ) ;
@@ -189,12 +212,12 @@ describe('GET /api/streak', () => {
189212 expect ( fetchGitHubContributions ) . not . toHaveBeenCalled ( ) ;
190213 } ) ;
191214
192- it ( 'returns 200 for unsupported ?layout query parameter values (route ignores it )' , async ( ) => {
215+ it ( 'returns 400 for unsupported ?layout query parameter values (strict schema validation )' , async ( ) => {
193216 const response = await GET (
194217 new Request ( 'http://localhost:3000/api/streak?user=octocat&layout=unsupported_layout' )
195218 ) ;
196219
197- expect ( response . status ) . toBe ( 200 ) ;
220+ expect ( response . status ) . toBe ( 400 ) ;
198221 } ) ;
199222
200223 it ( 'should return 200 OK and valid SVG when the optional repo query parameter is provided' , async ( ) => {
@@ -501,8 +524,6 @@ describe('GET /api/streak', () => {
501524 } ) ;
502525
503526 it ( 'defaults to linear scale when an unknown scale value is given' , async ( ) => {
504- // The route only accepts "log" — anything else is treated as "linear".
505- // A 200 response confirms no crash; the generator silently uses the default.
506527 const response = await GET ( makeRequest ( { user : 'octocat' , scale : 'exponential' } ) ) ;
507528
508529 expect ( response . status ) . toBe ( 200 ) ;
@@ -609,14 +630,12 @@ describe('GET /api/streak', () => {
609630 } ) ;
610631
611632 it ( 'accepts year=2008 (the earliest valid year)' , async ( ) => {
612- // 2008 is the GitHub founding year — the lower boundary of the valid range
613633 const response = await GET ( makeRequest ( { user : 'octocat' , year : '2008' } ) ) ;
614634
615635 expect ( response . status ) . toBe ( 200 ) ;
616636 } ) ;
617637
618638 it ( 'accepts the current year' , async ( ) => {
619- // Upper boundary — the current year must always be accepted
620639 const currentYear = new Date ( ) . getFullYear ( ) . toString ( ) ;
621640 const response = await GET ( makeRequest ( { user : 'octocat' , year : currentYear } ) ) ;
622641
@@ -657,7 +676,6 @@ describe('GET /api/streak', () => {
657676 } ) ;
658677
659678 it ( 'clamps negative radius to 0' , async ( ) => {
660- // sanitizeRadius uses Math.max(0, ...) so negatives must floor at 0
661679 const response = await GET ( makeRequest ( { user : 'octocat' , radius : '-5' } ) ) ;
662680 const body = await response . text ( ) ;
663681
@@ -666,7 +684,6 @@ describe('GET /api/streak', () => {
666684 } ) ;
667685
668686 it ( 'handles non-numeric radius gracefully' , async ( ) => {
669- // sanitizeRadius returns the fallback (8) when parseInt produces NaN
670687 const response = await GET ( makeRequest ( { user : 'octocat' , radius : 'abc' } ) ) ;
671688
672689 expect ( response . status ) . toBe ( 200 ) ;
@@ -712,7 +729,7 @@ describe('GET /api/streak', () => {
712729 const body = await response . text ( ) ;
713730
714731 expect ( response . status ) . toBe ( 200 ) ;
715- expect ( body ) . toContain ( '58a6ff' ) ; // Dark theme accent is #58a6ff — confirms the dark fallback is actually applied
732+ expect ( body ) . toContain ( '58a6ff' ) ;
716733 } ) ;
717734 } ) ;
718735
@@ -745,13 +762,7 @@ describe('GET /api/streak', () => {
745762 } ) ;
746763
747764 it ( 'returns 400 when an invalid hex color is passed as accent' , async ( ) => {
748- // #ZZZZZZ contains non-hex characters — schema must reject it with 400
749- const response = await GET ( makeRequest ( { user : 'octocat' , accent : '#ZZZZZZ' } ) ) ;
750-
751- expect ( response . status ) . toBe ( 400 ) ;
752- } ) ;
753- it ( 'returns 400 when another invalid hex color is passed as accent (Variation 4)' , async ( ) => {
754- const response = await GET ( makeRequest ( { user : 'octocat' , accent : '#ZZZZZZ' } ) ) ;
765+ const response = await GET ( makeRequest ( { user : 'octocat' , accent : '#ZZZZZZZ' } ) ) ;
755766
756767 expect ( response . status ) . toBe ( 400 ) ;
757768 } ) ;
@@ -838,8 +849,6 @@ describe('GET /api/streak', () => {
838849 } ) ;
839850
840851 it ( 'returns a valid 500 SVG even when something non-Error is thrown' , async ( ) => {
841- // JavaScript lets you throw anything — strings, numbers, plain objects.
842- // The catch block checks instanceof Error; if that fails it falls back to "Unknown error".
843852 vi . mocked ( fetchGitHubContributions ) . mockRejectedValue ( 'something went very wrong' ) ;
844853
845854 const response = await GET ( makeRequest ( { user : 'octocat' } ) ) ;
@@ -885,7 +894,6 @@ describe('GET /api/streak', () => {
885894 const body = await response . json ( ) ;
886895
887896 expect ( response . status ) . toBe ( 400 ) ;
888- // Zod validates the input and returns a JSON error — no raw user input reflected
889897 expect ( body . details . fieldErrors . tz [ 0 ] ) . toContain ( 'Invalid timezone' ) ;
890898 } ) ;
891899
@@ -896,8 +904,6 @@ describe('GET /api/streak', () => {
896904 } ) ;
897905
898906 it ( 'uses getSecondsUntilMidnightInTimezone (not UTC) for the cache TTL when ?tz= is set' , async ( ) => {
899- // getSecondsUntilMidnightInTimezone is mocked to return 7200 in beforeEach.
900- // getSecondsUntilUTCMidnight returns 3600. The header should use 7200.
901907 const response = await GET ( makeRequest ( { user : 'octocat' , tz : 'America/New_York' } ) ) ;
902908
903909 expect ( response . headers . get ( 'Cache-Control' ) ) . toBe (
@@ -926,13 +932,7 @@ describe('GET /api/streak', () => {
926932 expect ( getSecondsUntilMidnightInTimezone ) . toHaveBeenCalledWith ( 'Australia/Sydney' ) ;
927933 } ) ;
928934
929- // =========================================================================
930- // ISSUE OBJECTIVE: Reject fictitious planetary timezone (Variation 4)
931- // =========================================================================
932935 it ( 'returns 400 when a fictitious planetary timezone Mars/Cyonia is supplied' , async ( ) => {
933- // Mars/Cyonia is structurally plausible (Region/City format) but does not
934- // exist in the IANA tz database — the schema must reject it before the
935- // request reaches the GitHub API.
936936 const response = await GET ( makeRequest ( { user : 'octocat' , tz : 'Mars/Cyonia' } ) ) ;
937937
938938 expect ( response . status ) . toBe ( 400 ) ;
@@ -1020,23 +1020,18 @@ describe('GET /api/streak', () => {
10201020
10211021 expect ( response . status ) . toBe ( 200 ) ;
10221022 const body = await response . text ( ) ;
1023- // It should generate the default streak SVG and have "CURRENT_STREAK"
10241023 expect ( body ) . toContain ( 'CURRENT_STREAK' ) ;
10251024 } ) ;
10261025
10271026 it ( 'returns streak view when view=streak is given' , async ( ) => {
1028- // "streak" is not in the enum so .catch("default") applies — same output as default
10291027 const response = await GET ( makeRequest ( { user : 'octocat' , view : 'streak' } ) ) ;
10301028 const body = await response . text ( ) ;
10311029
10321030 expect ( response . status ) . toBe ( 200 ) ;
10331031 expect ( body ) . toContain ( 'CURRENT_STREAK' ) ;
10341032 } ) ;
1035- // =========================================================================
1036- // ISSUE OBJECTIVE: Custom dimensions in monthly view (?width & ?height)
1037- // =========================================================================
1033+
10381034 it ( 'applies custom width and height parameters to the monthly SVG' , async ( ) => {
1039- // 1. Create a fresh mock function and inject it globally just for this test
10401035 const tempMockFetch = vi . fn ( ) . mockResolvedValueOnce ( {
10411036 ok : true ,
10421037 json : async ( ) => ( {
@@ -1055,30 +1050,20 @@ describe('GET /api/streak', () => {
10551050 } ) ;
10561051 vi . stubGlobal ( 'fetch' , tempMockFetch ) ;
10571052
1058- // 2. Make request using the file's built-in makeRequest helper (No 'any' needed!)
10591053 const req = makeRequest ( { user : 'octocat' , view : 'monthly' , width : '400' , height : '150' } ) ;
10601054 const res = await GET ( req ) ;
10611055
1062- // Assert status is 200 OK
10631056 expect ( res . status ) . toBe ( 200 ) ;
10641057
10651058 const body = await res . text ( ) ;
10661059
1067- // 3. Assert body contains width="400"
10681060 expect ( body ) . toContain ( 'width="400"' ) ;
1069-
1070- // 4. Assert body contains height="150"
10711061 expect ( body ) . toContain ( 'height="150"' ) ;
10721062
1073- // Cleanup the stub so it doesn't leak into other tests
10741063 vi . unstubAllGlobals ( ) ;
10751064 } ) ;
10761065
1077- // =========================================================================
1078- // ISSUE OBJECTIVE: Route test for ?view=monthly&delta_format=both
1079- // =========================================================================
10801066 it ( 'applies delta_format=both to show percent and absolute values in the monthly SVG' , async ( ) => {
1081- // 1. Mock the GitHub fetch with actual weekly data using vi.mocked
10821067 vi . mocked ( fetchGitHubContributions ) . mockResolvedValueOnce ( {
10831068 calendar : {
10841069 totalContributions : 150 ,
@@ -1090,30 +1075,22 @@ describe('GET /api/streak', () => {
10901075 repoContributions : [ ] ,
10911076 } as unknown as ExtendedContributionData ) ;
10921077
1093- // 2. Lock the system time to May 2026 so the calendar calculation aligns
10941078 vi . useFakeTimers ( ) ;
10951079 vi . setSystemTime ( new Date ( '2026-05-20T12:00:00Z' ) ) ;
10961080
1097- // 3. Make request using the file's built-in makeRequest helper
10981081 const req = makeRequest ( { user : 'octocat' , view : 'monthly' , delta_format : 'both' } ) ;
10991082 const res = await GET ( req ) ;
11001083
11011084 expect ( res . status ) . toBe ( 200 ) ;
11021085
11031086 const body = await res . text ( ) ;
11041087
1105- // 4. Assert body contains % (percent part)
11061088 expect ( body ) . toContain ( '%' ) ;
11071089
1108- // Cleanup
11091090 vi . useRealTimers ( ) ;
11101091 } ) ;
11111092
1112- // =========================================================================
1113- // ISSUE OBJECTIVE: Route test for ?view=monthly&delta_format=absolute
1114- // =========================================================================
11151093 it ( 'applies delta_format=absolute to show raw commit counts in the monthly SVG' , async ( ) => {
1116- // 1. Mock the GitHub fetch with actual weekly data using vi.mocked
11171094 vi . mocked ( fetchGitHubContributions ) . mockResolvedValueOnce ( {
11181095 calendar : {
11191096 totalContributions : 150 ,
@@ -1124,22 +1101,19 @@ describe('GET /api/streak', () => {
11241101 } as ContributionCalendar ,
11251102 repoContributions : [ ] ,
11261103 } as unknown as ExtendedContributionData ) ;
1127- // 2. Lock the system time to May 2026 so the calendar calculation aligns
1104+
11281105 vi . useFakeTimers ( ) ;
11291106 vi . setSystemTime ( new Date ( '2026-05-20T12:00:00Z' ) ) ;
11301107
1131- // 3. Make request using the file's built-in makeRequest helper
11321108 const req = makeRequest ( { user : 'octocat' , view : 'monthly' , delta_format : 'absolute' } ) ;
11331109 const res = await GET ( req ) ;
11341110
11351111 expect ( res . status ) . toBe ( 200 ) ;
11361112
11371113 const body = await res . text ( ) ;
11381114
1139- // 4. Assert body contains 'commits' (the absolute delta unit)
11401115 expect ( body ) . toContain ( 'commits' ) ;
11411116
1142- // Cleanup
11431117 vi . useRealTimers ( ) ;
11441118 } ) ;
11451119 } ) ;
@@ -1174,6 +1148,7 @@ describe('GET /api/streak', () => {
11741148 expect ( body ) . toContain ( 'stroke-opacity="0.3"' ) ;
11751149 } ) ;
11761150 } ) ;
1151+
11771152 describe ( 'lang parameter' , ( ) => {
11781153 it ( 'returns Spanish translations when ?lang=es is given' , async ( ) => {
11791154 const response = await GET ( makeRequest ( { user : 'octocat' , lang : 'es' } ) ) ;
@@ -1214,7 +1189,6 @@ describe('GET /api/streak', () => {
12141189 const body = await response . text ( ) ;
12151190
12161191 expect ( response . status ) . toBe ( 200 ) ;
1217- // Default body font is Space Grotesk
12181192 expect ( body ) . toContain ( 'Space Grotesk' ) ;
12191193 } ) ;
12201194
@@ -1232,7 +1206,6 @@ describe('GET /api/streak', () => {
12321206
12331207 expect ( response . status ) . toBe ( 200 ) ;
12341208 expect ( body ) . toContain ( 'Space Grotesk' ) ;
1235- // Whitespace-only should not produce a Google Fonts import with an empty family
12361209 expect ( body ) . not . toContain ( 'family=+&display=swap' ) ;
12371210 } ) ;
12381211
@@ -1267,7 +1240,6 @@ describe('GET /api/streak', () => {
12671240
12681241 expect ( response . status ) . toBe ( 200 ) ;
12691242 expect ( body ) . toContain ( 'Space Grotesk' ) ;
1270- // No empty Google Fonts import should be emitted
12711243 expect ( body ) . not . toContain ( 'family=&display=swap' ) ;
12721244 } ) ;
12731245
@@ -1276,15 +1248,12 @@ describe('GET /api/streak', () => {
12761248 const body = await response . text ( ) ;
12771249
12781250 expect ( response . status ) . toBe ( 200 ) ;
1279- // The quote is stripped; the cleaned name "Inter" should still be used
12801251 expect ( body ) . toContain ( 'Inter' ) ;
1281- // The raw unescaped quote must not appear in the SVG output
12821252 expect ( body ) . not . toContain ( "font: 'Inter\"'" ) ;
12831253 expect ( body ) . not . toContain ( 'font-family: Inter"' ) ;
12841254 } ) ;
12851255
12861256 it ( 'rejects a font name containing a semicolon (CSS injection attempt)' , async ( ) => {
1287- // sanitizeGoogleFontUrl rejects names with semicolons — no Google Fonts import
12881257 const response = await GET ( makeRequest ( { user : 'octocat' , font : 'Inter; @import evil' } ) ) ;
12891258 const body = await response . text ( ) ;
12901259
@@ -1294,15 +1263,11 @@ describe('GET /api/streak', () => {
12941263 } ) ;
12951264
12961265 it ( 'strips special characters from a URL-like font name (path traversal / injection attempt)' , async ( ) => {
1297- // sanitizeFont strips ":", "/", "." — "https://evil.com" becomes "httpsevilcom"
1298- // sanitizeGoogleFontUrl then rejects it because "." is not in the whitelist
12991266 const response = await GET ( makeRequest ( { user : 'octocat' , font : 'https://evil.com' } ) ) ;
13001267 const body = await response . text ( ) ;
13011268
13021269 expect ( response . status ) . toBe ( 200 ) ;
1303- // The original URL must not appear verbatim in the SVG
13041270 expect ( body ) . not . toContain ( 'https://evil.com' ) ;
1305- // The domain must not appear in the output
13061271 expect ( body ) . not . toContain ( 'evil.com' ) ;
13071272 } ) ;
13081273
@@ -1318,14 +1283,11 @@ describe('GET /api/streak', () => {
13181283 } ) ;
13191284
13201285 it ( 'does not emit a Google Fonts import when a predefined font is used' , async ( ) => {
1321- // Predefined fonts (fira, jetbrains, roboto) are already bundled via the
1322- // static @import at the top of the <style> block — no extra import needed.
13231286 const response = await GET ( makeRequest ( { user : 'octocat' , font : 'fira' } ) ) ;
13241287 const body = await response . text ( ) ;
13251288
13261289 expect ( response . status ) . toBe ( 200 ) ;
13271290 expect ( body ) . toContain ( 'Fira Code' ) ;
1328- // Should NOT emit a second dynamic import for the same font
13291291 expect ( body ) . not . toContain ( 'family=fira&display=swap' ) ;
13301292 } ) ;
13311293
0 commit comments