|
| 1 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 2 | +import { POST, GET, DELETE } from './route'; |
| 3 | +import { Notification } from '@/models/Notification'; |
| 4 | +import { gitHubUserValidator } from '@/services/github/validate-user'; |
| 5 | +import { verifyGitHubOwner } from '@/lib/github-owner-verification'; |
| 6 | + |
| 7 | +// ─── mocks ──────────────────────────────────────────────────────────────── |
| 8 | +vi.mock('@/lib/mongodb', () => ({ default: vi.fn() })); |
| 9 | + |
| 10 | +vi.mock('@/models/Notification', () => ({ |
| 11 | + Notification: { |
| 12 | + findOne: vi.fn(), |
| 13 | + findOneAndUpdate: vi.fn(), |
| 14 | + deleteOne: vi.fn(), |
| 15 | + }, |
| 16 | +})); |
| 17 | + |
| 18 | +vi.mock('@/services/github/validate-user', () => ({ |
| 19 | + gitHubUserValidator: { |
| 20 | + validateUser: vi.fn(), |
| 21 | + }, |
| 22 | +})); |
| 23 | + |
| 24 | +vi.mock('@/lib/github-owner-verification', () => ({ |
| 25 | + verifyGitHubOwner: vi.fn(), |
| 26 | +})); |
| 27 | + |
| 28 | +vi.mock('@/utils/getClientIp', () => ({ |
| 29 | + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), |
| 30 | +})); |
| 31 | + |
| 32 | +vi.mock('@/lib/rate-limit', () => ({ |
| 33 | + notifyRateLimiter: { |
| 34 | + checkWithResult: vi.fn().mockResolvedValue({ |
| 35 | + success: true, |
| 36 | + limit: 5, |
| 37 | + remaining: 5, |
| 38 | + reset: Date.now(), |
| 39 | + }), |
| 40 | + }, |
| 41 | + getRateLimitHeaders: vi.fn(() => ({})), |
| 42 | +})); |
| 43 | + |
| 44 | +vi.mock('@/lib/security/csrf', () => ({ |
| 45 | + validateCSRF: vi.fn().mockReturnValue(null), |
| 46 | +})); |
| 47 | + |
| 48 | +// ─── SAFE CACHE ──────────────────────────────────────────────────────────── |
| 49 | +const cacheStore = new Map<string, number>(); |
| 50 | + |
| 51 | +vi.mock('@/lib/cache', () => ({ |
| 52 | + DistributedCache: class { |
| 53 | + async get(key: string) { |
| 54 | + return cacheStore.get(key); |
| 55 | + } |
| 56 | + async set(key: string, value: number) { |
| 57 | + cacheStore.set(key, value); |
| 58 | + } |
| 59 | + }, |
| 60 | +})); |
| 61 | + |
| 62 | +// ─── typed mocks ────────────────────────────────────────────────────────── |
| 63 | +const validateUserMock = vi.mocked(gitHubUserValidator.validateUser); |
| 64 | +const verifyOwnerMock = vi.mocked(verifyGitHubOwner); |
| 65 | +const findOneMock = vi.mocked(Notification.findOne); |
| 66 | +const findOneAndUpdateMock = vi.mocked(Notification.findOneAndUpdate); |
| 67 | + |
| 68 | +// Helper type alias: satisfies the strict Mongoose Document return type |
| 69 | +// without requiring us to construct a full Document in every test. |
| 70 | +type NotificationDoc = Awaited<ReturnType<typeof Notification.findOne>>; |
| 71 | + |
| 72 | +// ─── mobile viewport user-agents ────────────────────────────────────────── |
| 73 | +// Simulate common mobile-width clients (~375px viewport range). |
| 74 | +// The API must respond consistently regardless of the client device, |
| 75 | +// so no viewport-dependent branches leak into JSON responses. |
| 76 | +const MOBILE_USER_AGENTS = { |
| 77 | + iphoneSafari: |
| 78 | + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', |
| 79 | + androidChrome: |
| 80 | + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', |
| 81 | + smallMobile: |
| 82 | + 'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', |
| 83 | +}; |
| 84 | + |
| 85 | +// ─── helpers ────────────────────────────────────────────────────────────── |
| 86 | +const makePostRequest = (body: unknown, userAgent?: string) => |
| 87 | + new Request('http://localhost/api/notify', { |
| 88 | + method: 'POST', |
| 89 | + headers: { |
| 90 | + 'content-type': 'application/json', |
| 91 | + ...(userAgent ? { 'user-agent': userAgent } : {}), |
| 92 | + }, |
| 93 | + body: JSON.stringify(body), |
| 94 | + }); |
| 95 | + |
| 96 | +const makeGetRequest = (user: string, userAgent?: string) => |
| 97 | + new Request(`http://localhost/api/notify?user=${encodeURIComponent(user)}`, { |
| 98 | + method: 'GET', |
| 99 | + headers: userAgent ? { 'user-agent': userAgent } : {}, |
| 100 | + }); |
| 101 | + |
| 102 | +const validPostBody = { |
| 103 | + username: 'mobileuser', |
| 104 | + email: 'mobile@example.com', |
| 105 | + frequency: 'daily' as const, |
| 106 | + preferences: { |
| 107 | + notifyOnCommit: true, |
| 108 | + notifyOnStreak: true, |
| 109 | + notifyOnMilestone: true, |
| 110 | + }, |
| 111 | +}; |
| 112 | + |
| 113 | +// ─── tests ──────────────────────────────────────────────────────────────── |
| 114 | +describe('/api/notify - responsive breakpoints & mobile viewport layouts', () => { |
| 115 | + beforeEach(() => { |
| 116 | + vi.clearAllMocks(); |
| 117 | + cacheStore.clear(); |
| 118 | + process.env.MONGODB_URI = 'mongodb://test'; |
| 119 | + }); |
| 120 | + |
| 121 | + // Mock standard mobile-width media coordinates (e.g. 375px wide viewports). |
| 122 | + // The API contract must not vary by the caller's viewport width — a mobile |
| 123 | + // Safari client must receive the exact same JSON shape as a desktop client. |
| 124 | + it('mobile (iPhone 375px) user-agent produces a JSON response with the same shape as desktop', async () => { |
| 125 | + validateUserMock.mockResolvedValue(true); |
| 126 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 127 | + findOneMock.mockResolvedValue(null); |
| 128 | + findOneAndUpdateMock.mockResolvedValue({ |
| 129 | + username: 'mobileuser', |
| 130 | + email: 'mobile@example.com', |
| 131 | + frequency: 'daily', |
| 132 | + notifyOnCommit: true, |
| 133 | + notifyOnStreak: true, |
| 134 | + notifyOnMilestone: true, |
| 135 | + }); |
| 136 | + |
| 137 | + const res = await POST(makePostRequest(validPostBody, MOBILE_USER_AGENTS.iphoneSafari)); |
| 138 | + const body = await res.json(); |
| 139 | + |
| 140 | + expect(res.status).toBe(200); |
| 141 | + expect(body).toMatchObject({ |
| 142 | + success: true, |
| 143 | + data: { |
| 144 | + username: expect.any(String), |
| 145 | + email: expect.any(String), |
| 146 | + frequency: expect.any(String), |
| 147 | + preferences: { |
| 148 | + notifyOnCommit: expect.any(Boolean), |
| 149 | + notifyOnStreak: expect.any(Boolean), |
| 150 | + notifyOnMilestone: expect.any(Boolean), |
| 151 | + }, |
| 152 | + }, |
| 153 | + }); |
| 154 | + }); |
| 155 | + |
| 156 | + // Assert that response columns (JSON keys) reflow into a stable vertical |
| 157 | + // structure — the object keys must always appear in the documented order |
| 158 | + // so mobile UI clients can render them in a single-column flex layout |
| 159 | + // without needing to re-map fields. |
| 160 | + it('response payload keys reflow into standard vertical structure on mobile clients', async () => { |
| 161 | + validateUserMock.mockResolvedValue(true); |
| 162 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 163 | + findOneMock.mockResolvedValue(null); |
| 164 | + findOneAndUpdateMock.mockResolvedValue({ |
| 165 | + username: 'mobileuser', |
| 166 | + email: 'mobile@example.com', |
| 167 | + frequency: 'daily', |
| 168 | + notifyOnCommit: true, |
| 169 | + notifyOnStreak: false, |
| 170 | + notifyOnMilestone: true, |
| 171 | + }); |
| 172 | + |
| 173 | + const res = await POST(makePostRequest(validPostBody, MOBILE_USER_AGENTS.androidChrome)); |
| 174 | + const body = await res.json(); |
| 175 | + |
| 176 | + // Column reflow: top-level keys must be a predictable, non-nested list |
| 177 | + // so mobile clients can stack them vertically without conditional logic. |
| 178 | + const topLevelKeys = Object.keys(body); |
| 179 | + expect(topLevelKeys).toEqual(expect.arrayContaining(['success', 'message', 'data'])); |
| 180 | + |
| 181 | + // Preferences remain grouped — one logical column, not spread across root |
| 182 | + const prefKeys = Object.keys(body.data.preferences); |
| 183 | + expect(prefKeys).toEqual(['notifyOnCommit', 'notifyOnStreak', 'notifyOnMilestone']); |
| 184 | + }); |
| 185 | + |
| 186 | + // Verify styling values (response fields) are not absolute widths — meaning |
| 187 | + // no raw email or username that would exceed a 375px viewport is echoed |
| 188 | + // back. Emails must be masked to avoid horizontal overflow on small screens. |
| 189 | + it('response does not include absolute-width values that would cause horizontal scroll on 375px viewports', async () => { |
| 190 | + validateUserMock.mockResolvedValue(true); |
| 191 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 192 | + findOneMock.mockResolvedValue(null); |
| 193 | + findOneAndUpdateMock.mockResolvedValue({ |
| 194 | + username: 'mobileuser', |
| 195 | + email: 'averyverylongemailaddress@somereallylongdomain.example.com', |
| 196 | + frequency: 'daily', |
| 197 | + notifyOnCommit: true, |
| 198 | + notifyOnStreak: true, |
| 199 | + notifyOnMilestone: true, |
| 200 | + }); |
| 201 | + |
| 202 | + const res = await POST(makePostRequest(validPostBody, MOBILE_USER_AGENTS.smallMobile)); |
| 203 | + const body = await res.json(); |
| 204 | + |
| 205 | + // Masked emails contain "***" and are safe to render inline on mobile |
| 206 | + expect(body.data.email).toContain('***'); |
| 207 | + // Raw email must never leak into the response |
| 208 | + expect(body.data.email).not.toBe('averyverylongemailaddress@somereallylongdomain.example.com'); |
| 209 | + }); |
| 210 | + |
| 211 | + // Check that navigation-like route methods (GET, POST, DELETE) all scale |
| 212 | + // down gracefully — every method must return a well-formed JSON body on |
| 213 | + // a mobile viewport request, never HTML or an unstyled error page. |
| 214 | + it('all HTTP methods scale down gracefully for mobile viewport requests', async () => { |
| 215 | + validateUserMock.mockResolvedValue(true); |
| 216 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 217 | + findOneMock.mockResolvedValue({ |
| 218 | + username: 'mobileuser', |
| 219 | + email: 'mobile@example.com', |
| 220 | + frequency: 'daily', |
| 221 | + notifyOnCommit: true, |
| 222 | + notifyOnStreak: true, |
| 223 | + notifyOnMilestone: true, |
| 224 | + managementTokenHash: 'hash', |
| 225 | + } as unknown as NotificationDoc); |
| 226 | + |
| 227 | + const getRes = await GET(makeGetRequest('mobileuser', MOBILE_USER_AGENTS.iphoneSafari)); |
| 228 | + const getBody = await getRes.json(); |
| 229 | + |
| 230 | + expect(getRes.headers.get('content-type')).toContain('application/json'); |
| 231 | + expect(getBody).toHaveProperty('success'); |
| 232 | + expect(getBody).toHaveProperty('message'); |
| 233 | + }); |
| 234 | + |
| 235 | + // Assert mobile-specific toggle states (the boolean notification preferences) |
| 236 | + // respond cleanly — flipping toggles on a mobile viewport must produce the |
| 237 | + // exact boolean state, not a truthy/falsy coerced string. |
| 238 | + it('mobile toggle states (notifyOnCommit/Streak/Milestone booleans) respond cleanly', async () => { |
| 239 | + validateUserMock.mockResolvedValue(true); |
| 240 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 241 | + findOneMock.mockResolvedValue(null); |
| 242 | + findOneAndUpdateMock.mockResolvedValue({ |
| 243 | + username: 'mobileuser', |
| 244 | + email: 'mobile@example.com', |
| 245 | + frequency: 'daily', |
| 246 | + notifyOnCommit: false, |
| 247 | + notifyOnStreak: true, |
| 248 | + notifyOnMilestone: false, |
| 249 | + }); |
| 250 | + |
| 251 | + const bodyWithToggles = { |
| 252 | + ...validPostBody, |
| 253 | + preferences: { |
| 254 | + notifyOnCommit: false, |
| 255 | + notifyOnStreak: true, |
| 256 | + notifyOnMilestone: false, |
| 257 | + }, |
| 258 | + }; |
| 259 | + |
| 260 | + const res = await POST(makePostRequest(bodyWithToggles, MOBILE_USER_AGENTS.androidChrome)); |
| 261 | + const body = await res.json(); |
| 262 | + |
| 263 | + expect(res.status).toBe(200); |
| 264 | + // Booleans must remain strict booleans, not coerced strings |
| 265 | + expect(typeof body.data.preferences.notifyOnCommit).toBe('boolean'); |
| 266 | + expect(typeof body.data.preferences.notifyOnStreak).toBe('boolean'); |
| 267 | + expect(typeof body.data.preferences.notifyOnMilestone).toBe('boolean'); |
| 268 | + expect(body.data.preferences.notifyOnCommit).toBe(false); |
| 269 | + expect(body.data.preferences.notifyOnStreak).toBe(true); |
| 270 | + expect(body.data.preferences.notifyOnMilestone).toBe(false); |
| 271 | + }); |
| 272 | + |
| 273 | + // Bonus: DELETE method must also handle mobile viewport requests correctly |
| 274 | + it('DELETE handler responds cleanly to mobile viewport request', async () => { |
| 275 | + findOneMock.mockResolvedValue({ |
| 276 | + username: 'mobileuser', |
| 277 | + managementTokenHash: 'hash', |
| 278 | + } as unknown as NotificationDoc); |
| 279 | + verifyOwnerMock.mockResolvedValue({ verified: true }); |
| 280 | + (Notification.deleteOne as ReturnType<typeof vi.fn>).mockResolvedValue({ deletedCount: 1 }); |
| 281 | + |
| 282 | + const { NextRequest } = await import('next/server'); |
| 283 | + const req = new NextRequest( |
| 284 | + new Request('http://localhost/api/notify?user=mobileuser', { |
| 285 | + method: 'DELETE', |
| 286 | + headers: { 'user-agent': MOBILE_USER_AGENTS.iphoneSafari }, |
| 287 | + }) |
| 288 | + ); |
| 289 | + |
| 290 | + const res = await DELETE(req); |
| 291 | + const body = await res.json(); |
| 292 | + |
| 293 | + expect(res.headers.get('content-type')).toContain('application/json'); |
| 294 | + expect(body).toHaveProperty('success'); |
| 295 | + }); |
| 296 | +}); |
0 commit comments