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
44 changes: 44 additions & 0 deletions app/api/compare/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
29 changes: 28 additions & 1 deletion app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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 });
Expand Down
Loading