Skip to content

Commit bf4f5ea

Browse files
Merge branch 'main' into fix-benchmark-invalid-dates
2 parents 715fb2f + a4dcf09 commit bf4f5ea

40 files changed

Lines changed: 2738 additions & 2185 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [main, dev]
66
pull_request:
77
branches: [main, dev]
8+
merge_group: # Run CI on the merged commit before it lands on main
89

910
jobs:
1011
quality:

app/api/notify/route.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
import { GET, POST } from './route';
4+
5+
// Mock dependencies
6+
vi.mock('@/lib/mongodb', () => ({ default: vi.fn() }));
7+
vi.mock('@/models/Notification', () => ({
8+
Notification: {
9+
findOneAndUpdate: vi.fn(),
10+
findOne: vi.fn(),
11+
},
12+
}));
13+
14+
import { Notification } from '@/models/Notification';
15+
16+
const makeRequest = (method: string, body?: object, search?: string) => {
17+
const url = `http://localhost:3000/api/notify${search ? '?' + search : ''}`;
18+
return new NextRequest(url, {
19+
method,
20+
body: body ? JSON.stringify(body) : undefined,
21+
});
22+
};
23+
24+
describe('POST /api/notify', () => {
25+
beforeEach(() => vi.clearAllMocks());
26+
27+
it('returns 400 when username is missing', async () => {
28+
const res = await POST(makeRequest('POST', { email: 'test@test.com' }));
29+
expect(res.status).toBe(400);
30+
});
31+
32+
it('returns 400 when email is missing', async () => {
33+
const res = await POST(makeRequest('POST', { username: 'testuser' }));
34+
expect(res.status).toBe(400);
35+
});
36+
37+
it('returns 400 for invalid email format', async () => {
38+
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'notanemail' }));
39+
expect(res.status).toBe(400);
40+
});
41+
42+
it('returns 400 for invalid frequency', async () => {
43+
const res = await POST(
44+
makeRequest('POST', { username: 'testuser', email: 'a@b.com', frequency: 'monthly' })
45+
);
46+
expect(res.status).toBe(400);
47+
});
48+
49+
it('returns 200 and saves preferences successfully', async () => {
50+
vi.mocked(Notification.findOneAndUpdate).mockResolvedValue({
51+
username: 'testuser',
52+
email: 'a@b.com',
53+
frequency: 'daily',
54+
notifyOnCommit: true,
55+
notifyOnStreak: true,
56+
notifyOnMilestone: true,
57+
} as never);
58+
59+
const res = await POST(
60+
makeRequest('POST', {
61+
username: 'testuser',
62+
email: 'a@b.com',
63+
frequency: 'daily',
64+
preferences: { notifyOnCommit: true, notifyOnStreak: true, notifyOnMilestone: true },
65+
})
66+
);
67+
expect(res.status).toBe(200);
68+
});
69+
});
70+
71+
describe('GET /api/notify', () => {
72+
beforeEach(() => vi.clearAllMocks());
73+
74+
it('returns 400 when username is missing', async () => {
75+
const res = await GET(makeRequest('GET'));
76+
expect(res.status).toBe(400);
77+
});
78+
79+
it('returns 404 when user not found', async () => {
80+
vi.mocked(Notification.findOne).mockResolvedValue(null);
81+
const res = await GET(makeRequest('GET', undefined, 'user=nobody'));
82+
expect(res.status).toBe(404);
83+
});
84+
85+
it('returns 200 with preferences when user exists', async () => {
86+
vi.mocked(Notification.findOne).mockResolvedValue({
87+
username: 'testuser',
88+
email: 'a@b.com',
89+
frequency: 'daily',
90+
notifyOnCommit: true,
91+
notifyOnStreak: true,
92+
notifyOnMilestone: true,
93+
} as never);
94+
95+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
96+
expect(res.status).toBe(200);
97+
});
98+
});

app/api/notify/route.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import dbConnect from '@/lib/mongodb';
3+
import { Notification } from '@/models/Notification';
4+
import { NotificationPayload, NotificationResponse } from '@/types/index';
5+
6+
// ─── POST /api/notify ────────────────────────────────────────────────────────
7+
// Register or update email notification preferences for a user
8+
export async function POST(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
9+
try {
10+
const body: NotificationPayload = await req.json();
11+
const { username, email, frequency, preferences } = body;
12+
13+
// Validate required fields
14+
if (!username || !email) {
15+
return NextResponse.json(
16+
{ success: false, message: 'Username and email are required.' },
17+
{ status: 400 }
18+
);
19+
}
20+
21+
// Validate email format
22+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23+
if (!emailRegex.test(email)) {
24+
return NextResponse.json(
25+
{ success: false, message: 'Invalid email address.' },
26+
{ status: 400 }
27+
);
28+
}
29+
30+
// Validate frequency
31+
const validFrequencies = ['realtime', 'daily', 'weekly'];
32+
if (frequency && !validFrequencies.includes(frequency)) {
33+
return NextResponse.json(
34+
{ success: false, message: 'Invalid frequency. Use realtime, daily, or weekly.' },
35+
{ status: 400 }
36+
);
37+
}
38+
39+
await dbConnect();
40+
41+
// Upsert notification preferences
42+
const notification = await Notification.findOneAndUpdate(
43+
{ username: username.toLowerCase() },
44+
{
45+
email: email.toLowerCase(),
46+
frequency: frequency ?? 'daily',
47+
notifyOnCommit: preferences?.notifyOnCommit ?? true,
48+
notifyOnStreak: preferences?.notifyOnStreak ?? true,
49+
notifyOnMilestone: preferences?.notifyOnMilestone ?? true,
50+
isActive: true,
51+
updatedAt: new Date(),
52+
},
53+
{ upsert: true, new: true }
54+
);
55+
56+
return NextResponse.json(
57+
{
58+
success: true,
59+
message: 'Notification preferences saved successfully.',
60+
data: {
61+
username: notification.username,
62+
email: notification.email,
63+
frequency: notification.frequency,
64+
preferences: {
65+
notifyOnCommit: notification.notifyOnCommit,
66+
notifyOnStreak: notification.notifyOnStreak,
67+
notifyOnMilestone: notification.notifyOnMilestone,
68+
},
69+
},
70+
},
71+
{ status: 200 }
72+
);
73+
} catch (error) {
74+
console.error('[/api/notify] Error saving notification preferences:', error);
75+
return NextResponse.json(
76+
{ success: false, message: 'Internal server error.' },
77+
{ status: 500 }
78+
);
79+
}
80+
}
81+
82+
// ─── GET /api/notify ─────────────────────────────────────────────────────────
83+
// Fetch notification preferences for a user
84+
export async function GET(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
85+
try {
86+
const { searchParams } = new URL(req.url);
87+
const username = searchParams.get('user');
88+
89+
if (!username) {
90+
return NextResponse.json(
91+
{ success: false, message: 'Username is required.' },
92+
{ status: 400 }
93+
);
94+
}
95+
96+
await dbConnect();
97+
98+
const notification = await Notification.findOne({
99+
username: username.toLowerCase(),
100+
});
101+
102+
if (!notification) {
103+
return NextResponse.json(
104+
{ success: false, message: 'No notification preferences found.' },
105+
{ status: 404 }
106+
);
107+
}
108+
109+
return NextResponse.json(
110+
{
111+
success: true,
112+
message: 'Notification preferences fetched successfully.',
113+
data: {
114+
username: notification.username,
115+
email: notification.email,
116+
frequency: notification.frequency,
117+
preferences: {
118+
notifyOnCommit: notification.notifyOnCommit,
119+
notifyOnStreak: notification.notifyOnStreak,
120+
notifyOnMilestone: notification.notifyOnMilestone,
121+
},
122+
},
123+
},
124+
{ status: 200 }
125+
);
126+
} catch (error) {
127+
console.error('[/api/notify] Error fetching notification preferences:', error);
128+
return NextResponse.json(
129+
{ success: false, message: 'Internal server error.' },
130+
{ status: 500 }
131+
);
132+
}
133+
}

app/api/stats/route.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,36 @@ describe('GET /api/stats', () => {
103103
expect(typeof body.currentStreak).toBe('number');
104104
});
105105

106+
it('returns non-cacheable headers when refresh=true', async () => {
107+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
108+
109+
expect(response.status).toBe(200);
110+
expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate');
111+
expect(response.headers.get('Pragma')).toBe('no-cache');
112+
expect(response.headers.get('Expires')).toBe('0');
113+
});
114+
115+
it('still returns valid stats data when refresh=true', async () => {
116+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
117+
const body = await response.json();
118+
119+
expect(response.status).toBe(200);
120+
expect(body.totalContributions).toBe(10);
121+
expect(typeof body.longestStreak).toBe('number');
122+
expect(typeof body.currentStreak).toBe('number');
123+
});
124+
125+
it('keeps the existing cache headers for normal requests', async () => {
126+
const response = await GET(makeRequest({ user: 'testuser' }));
127+
128+
expect(response.status).toBe(200);
129+
expect(response.headers.get('Cache-Control')).toBe(
130+
'public, s-maxage=3600, stale-while-revalidate=86400'
131+
);
132+
expect(response.headers.get('Pragma')).toBeNull();
133+
expect(response.headers.get('Expires')).toBeNull();
134+
});
135+
106136
it('passes bypassCache=true to GitHub when refresh=true', async () => {
107137
await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
108138
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: true });

app/api/stats/route.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,24 @@ export async function GET(request: Request) {
5050
const userData = await fetchGitHubContributions(user, { bypassCache: refresh });
5151
const calendar = userData.calendar;
5252
const stats = calculateStreak(calendar, timezone);
53+
const headers = new Headers({
54+
// Cache until next UTC midnight; clients can bust with ?refresh=true
55+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
56+
});
57+
58+
if (refresh) {
59+
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
60+
headers.set('Pragma', 'no-cache');
61+
headers.set('Expires', '0');
62+
}
5363

5464
return NextResponse.json(
5565
{
5666
totalContributions: stats.totalContributions,
5767
longestStreak: stats.longestStreak,
5868
currentStreak: stats.currentStreak,
5969
},
60-
{
61-
headers: {
62-
// Cache until next UTC midnight; clients can bust with ?refresh=true
63-
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
64-
},
65-
}
70+
{ headers }
6671
);
6772
} catch (error: unknown) {
6873
const message = error instanceof Error ? error.message : 'Unknown error';

0 commit comments

Comments
 (0)