1- import { describe , it , expect , vi , beforeEach } from 'vitest' ;
1+ import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
22import { NextRequest } from 'next/server' ;
33import { GET , POST } from './route' ;
44
@@ -10,19 +10,38 @@ vi.mock('@/models/Notification', () => ({
1010 findOne : vi . fn ( ) ,
1111 } ,
1212} ) ) ;
13+ vi . mock ( '@/lib/rate-limit' , ( ) => ( {
14+ notifyRateLimiter : {
15+ check : vi . fn ( ) . mockResolvedValue ( true ) ,
16+ } ,
17+ } ) ) ;
1318
1419import { Notification } from '@/models/Notification' ;
20+ import { notifyRateLimiter } from '@/lib/rate-limit' ;
1521
1622const makeRequest = ( method : string , body ?: object , search ?: string ) => {
1723 const url = `http://localhost:3000/api/notify${ search ? '?' + search : '' } ` ;
1824 return new NextRequest ( url , {
1925 method,
26+ headers : { 'x-forwarded-for' : '127.0.0.1' } ,
2027 body : body ? JSON . stringify ( body ) : undefined ,
2128 } ) ;
2229} ;
2330
2431describe ( 'POST /api/notify' , ( ) => {
25- beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
32+ const originalEnv = process . env ;
33+
34+ beforeEach ( ( ) => {
35+ vi . clearAllMocks ( ) ;
36+ process . env = { ...originalEnv , MONGODB_URI : 'mongodb://localhost/test' } ;
37+ vi . mocked ( notifyRateLimiter . check ) . mockResolvedValue ( true ) ;
38+ } ) ;
39+
40+ afterEach ( ( ) => {
41+ process . env = originalEnv ;
42+ } ) ;
43+
44+ // ── Validation ────────────────────────────────────────────────────────────
2645
2746 it ( 'returns 400 when username is missing' , async ( ) => {
2847 const res = await POST ( makeRequest ( 'POST' , { email : 'test@test.com' } ) ) ;
@@ -46,6 +65,62 @@ describe('POST /api/notify', () => {
4665 expect ( res . status ) . toBe ( 400 ) ;
4766 } ) ;
4867
68+ it ( 'returns 400 for invalid GitHub username format' , async ( ) => {
69+ const res = await POST ( makeRequest ( 'POST' , { username : '-invalid' , email : 'a@b.com' } ) ) ;
70+ expect ( res . status ) . toBe ( 400 ) ;
71+ const data = await res . json ( ) ;
72+ expect ( data . message ) . toContain ( 'Invalid GitHub username' ) ;
73+ } ) ;
74+
75+ it ( 'returns 400 for username exceeding 39 characters' , async ( ) => {
76+ const res = await POST ( makeRequest ( 'POST' , { username : 'a' . repeat ( 40 ) , email : 'a@b.com' } ) ) ;
77+ expect ( res . status ) . toBe ( 400 ) ;
78+ } ) ;
79+
80+ it ( 'returns 400 for malformed JSON body' , async ( ) => {
81+ const url = 'http://localhost:3000/api/notify' ;
82+ const req = new NextRequest ( url , {
83+ method : 'POST' ,
84+ headers : { 'x-forwarded-for' : '127.0.0.1' , 'content-type' : 'application/json' } ,
85+ body : 'not-json' ,
86+ } ) ;
87+ const res = await POST ( req ) ;
88+ expect ( res . status ) . toBe ( 400 ) ;
89+ const data = await res . json ( ) ;
90+ expect ( data . message ) . toContain ( 'Malformed JSON' ) ;
91+ } ) ;
92+
93+ // ── Rate limiting ────────────────────────────────────────────────────────
94+
95+ it ( 'returns 429 when rate limited' , async ( ) => {
96+ vi . mocked ( notifyRateLimiter . check ) . mockResolvedValue ( false ) ;
97+ const res = await POST ( makeRequest ( 'POST' , { username : 'testuser' , email : 'a@b.com' } ) ) ;
98+ expect ( res . status ) . toBe ( 429 ) ;
99+ } ) ;
100+
101+ // ── MONGODB_URI handling ──────────────────────────────────────────────────
102+
103+ it ( 'returns 500 when MONGODB_URI is not set in production' , async ( ) => {
104+ delete process . env . MONGODB_URI ;
105+ vi . stubEnv ( 'NODE_ENV' , 'production' ) ;
106+ const res = await POST ( makeRequest ( 'POST' , { username : 'testuser' , email : 'a@b.com' } ) ) ;
107+ expect ( res . status ) . toBe ( 500 ) ;
108+ const data = await res . json ( ) ;
109+ expect ( data . message ) . toContain ( 'Database configuration error' ) ;
110+ } ) ;
111+
112+ it ( 'bypasses gracefully when MONGODB_URI is not set in development' , async ( ) => {
113+ delete process . env . MONGODB_URI ;
114+ vi . stubEnv ( 'NODE_ENV' , 'development' ) ;
115+ const res = await POST ( makeRequest ( 'POST' , { username : 'testuser' , email : 'a@b.com' } ) ) ;
116+ expect ( res . status ) . toBe ( 200 ) ;
117+ const data = await res . json ( ) ;
118+ expect ( data . success ) . toBe ( true ) ;
119+ expect ( data . message ) . toContain ( 'bypassed' ) ;
120+ } ) ;
121+
122+ // ── Success ──────────────────────────────────────────────────────────────
123+
49124 it ( 'returns 200 and saves preferences successfully' , async ( ) => {
50125 vi . mocked ( Notification . findOneAndUpdate ) . mockResolvedValue ( {
51126 username : 'testuser' ,
@@ -65,17 +140,81 @@ describe('POST /api/notify', () => {
65140 } )
66141 ) ;
67142 expect ( res . status ) . toBe ( 200 ) ;
143+ const data = await res . json ( ) ;
144+ expect ( data . success ) . toBe ( true ) ;
145+ expect ( data . data . username ) . toBe ( 'testuser' ) ;
146+ } ) ;
147+
148+ it ( 'defaults frequency to daily and preferences to true when omitted' , async ( ) => {
149+ vi . mocked ( Notification . findOneAndUpdate ) . mockResolvedValue ( {
150+ username : 'testuser' ,
151+ email : 'a@b.com' ,
152+ frequency : 'daily' ,
153+ notifyOnCommit : true ,
154+ notifyOnStreak : true ,
155+ notifyOnMilestone : true ,
156+ } as never ) ;
157+
158+ const res = await POST ( makeRequest ( 'POST' , { username : 'testuser' , email : 'a@b.com' } ) ) ;
159+ expect ( res . status ) . toBe ( 200 ) ;
68160 } ) ;
69161} ) ;
70162
71163describe ( 'GET /api/notify' , ( ) => {
72- beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
164+ const originalEnv = process . env ;
165+
166+ beforeEach ( ( ) => {
167+ vi . clearAllMocks ( ) ;
168+ process . env = { ...originalEnv , MONGODB_URI : 'mongodb://localhost/test' } ;
169+ vi . mocked ( notifyRateLimiter . check ) . mockResolvedValue ( true ) ;
170+ } ) ;
171+
172+ afterEach ( ( ) => {
173+ process . env = originalEnv ;
174+ } ) ;
175+
176+ // ── Validation ────────────────────────────────────────────────────────────
73177
74178 it ( 'returns 400 when username is missing' , async ( ) => {
75179 const res = await GET ( makeRequest ( 'GET' ) ) ;
76180 expect ( res . status ) . toBe ( 400 ) ;
77181 } ) ;
78182
183+ it ( 'returns 400 for invalid GitHub username format' , async ( ) => {
184+ const res = await GET ( makeRequest ( 'GET' , undefined , 'user=-invalid' ) ) ;
185+ expect ( res . status ) . toBe ( 400 ) ;
186+ const data = await res . json ( ) ;
187+ expect ( data . message ) . toContain ( 'Invalid GitHub username' ) ;
188+ } ) ;
189+
190+ // ── Rate limiting ────────────────────────────────────────────────────────
191+
192+ it ( 'returns 429 when rate limited' , async ( ) => {
193+ vi . mocked ( notifyRateLimiter . check ) . mockResolvedValue ( false ) ;
194+ const res = await GET ( makeRequest ( 'GET' , undefined , 'user=testuser' ) ) ;
195+ expect ( res . status ) . toBe ( 429 ) ;
196+ } ) ;
197+
198+ // ── MONGODB_URI handling ──────────────────────────────────────────────────
199+
200+ it ( 'returns 500 when MONGODB_URI is not set in production' , async ( ) => {
201+ delete process . env . MONGODB_URI ;
202+ vi . stubEnv ( 'NODE_ENV' , 'production' ) ;
203+ const res = await GET ( makeRequest ( 'GET' , undefined , 'user=testuser' ) ) ;
204+ expect ( res . status ) . toBe ( 500 ) ;
205+ } ) ;
206+
207+ it ( 'bypasses gracefully when MONGODB_URI is not set in development' , async ( ) => {
208+ delete process . env . MONGODB_URI ;
209+ vi . stubEnv ( 'NODE_ENV' , 'development' ) ;
210+ const res = await GET ( makeRequest ( 'GET' , undefined , 'user=testuser' ) ) ;
211+ const data = await res . json ( ) ;
212+ expect ( data . success ) . toBe ( false ) ;
213+ expect ( data . message ) . toContain ( 'no database configured' ) ;
214+ } ) ;
215+
216+ // ── Data fetching ────────────────────────────────────────────────────────
217+
79218 it ( 'returns 404 when user not found' , async ( ) => {
80219 vi . mocked ( Notification . findOne ) . mockResolvedValue ( null ) ;
81220 const res = await GET ( makeRequest ( 'GET' , undefined , 'user=nobody' ) ) ;
@@ -94,6 +233,9 @@ describe('GET /api/notify', () => {
94233
95234 const res = await GET ( makeRequest ( 'GET' , undefined , 'user=testuser' ) ) ;
96235 expect ( res . status ) . toBe ( 200 ) ;
236+ const data = await res . json ( ) ;
237+ expect ( data . success ) . toBe ( true ) ;
238+ expect ( data . data . username ) . toBe ( 'testuser' ) ;
97239 } ) ;
98240
99241 it ( 'masks the email address in GET responses to prevent PII exposure' , async ( ) => {
0 commit comments