diff --git a/app/api/compare/route.test.ts b/app/api/compare/route.test.ts index 1c9f84283..6c80c40ce 100644 --- a/app/api/compare/route.test.ts +++ b/app/api/compare/route.test.ts @@ -160,4 +160,48 @@ describe('GET /api/compare', () => { expect(res.status).toBe(500); }); + + // ── ETag & Caching ───────────────────────────────────────────────────────── + + it('adds ETag and Cache-Control headers to 200 response', async () => { + const res = await GET(makeRequest('user1=alice&user2=bob')); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBeTruthy(); + expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=3600'); + }); + + it('returns 304 Not Modified when If-None-Match matches weak ETag', async () => { + const res1 = await GET(makeRequest('user1=alice&user2=bob')); + const etag = res1.headers.get('ETag'); + expect(etag).toBeTruthy(); + + const requestWithEtag = new Request('http://localhost:3000/api/compare?user1=alice&user2=bob', { + headers: { + 'if-none-match': etag!, + }, + }); + + const res2 = await GET(requestWithEtag); + expect(res2.status).toBe(304); + expect(res2.body).toBeNull(); + expect(res2.headers.get('ETag')).toBe(etag); + expect(res2.headers.get('Cache-Control')).toBe('public, s-maxage=3600'); + }); + + it('returns 304 Not Modified when If-None-Match matches strong ETag', async () => { + const res1 = await GET(makeRequest('user1=alice&user2=bob')); + const etag = res1.headers.get('ETag'); + expect(etag).toBeTruthy(); + + const strongEtag = etag!.replace(/^W\//, ''); + + const requestWithEtag = new Request('http://localhost:3000/api/compare?user1=alice&user2=bob', { + headers: { + 'if-none-match': strongEtag, + }, + }); + + const res2 = await GET(requestWithEtag); + expect(res2.status).toBe(304); + }); }); diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index d8a33315a..2aad1899a 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getFullDashboardData } from '@/lib/github'; import { compareParamsSchema } from '@/lib/validations'; +import crypto from 'crypto'; export const revalidate = 3600; @@ -77,10 +78,36 @@ export async function GET(request: Request) { return buildCompareFetchErrorResponse(user2, result2.reason); } - return NextResponse.json({ + const jsonPayload = JSON.stringify({ user1: result1.value, user2: result2.value, }); + + const etag = crypto.createHash('sha1').update(jsonPayload).digest('hex'); + const weakEtag = `W/"${etag}"`; + const ifNoneMatch = request.headers.get('if-none-match'); + const cacheControl = 'public, s-maxage=3600'; + + if (ifNoneMatch) { + const etags = ifNoneMatch.split(',').map((e) => e.trim()); + if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) { + return new NextResponse(null, { + status: 304, + headers: { + 'Cache-Control': cacheControl, + ETag: weakEtag, + }, + }); + } + } + + return new NextResponse(jsonPayload, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': cacheControl, + ETag: weakEtag, + }, + }); } catch (error) { const message = error instanceof Error ? error.message : 'Internal server error'; return NextResponse.json({ error: message }, { status: 500 });