Skip to content

Commit 4383bd0

Browse files
committed
Add unit tests to api utils and contract endpoint
1 parent a566650 commit 4383bd0

8 files changed

Lines changed: 350 additions & 109 deletions

File tree

‎src/pages/api/_utils/__tests__/gdpr-email.spec.ts‎

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,35 @@
11
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
22
import { sendDSARVerificationEmail } from '@pages/api/_utils/gdpr-email'
33

4+
// Create mock send function at module level
5+
const mockSend = vi.fn()
6+
47
// Mock the Resend module
58
vi.mock('resend', () => {
6-
const mockSend = vi.fn()
79
return {
8-
Resend: vi.fn().mockImplementation(() => ({
9-
emails: {
10-
send: mockSend,
11-
},
12-
})),
10+
Resend: vi.fn(function ResendMock(_apiKey) {
11+
return {
12+
emails: {
13+
send: mockSend,
14+
},
15+
}
16+
}),
1317
}
1418
})
1519

1620
describe('GDPR Email Utils', () => {
17-
let mockResendSend: ReturnType<typeof vi.fn>
1821
let originalEnv: Record<string, string | undefined>
1922
let consoleLogSpy: ReturnType<typeof vi.fn>
2023
let consoleErrorSpy: ReturnType<typeof vi.fn>
2124

22-
beforeEach(async () => {
25+
beforeEach(() => {
2326
// Store original env
2427
originalEnv = { ...process.env }
2528

2629
// Mock console methods
2730
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
2831
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
2932

30-
// Get the mock function from the mocked Resend class
31-
const { Resend } = await import('resend')
32-
const resendInstance = new Resend('test-key')
33-
mockResendSend = resendInstance.emails.send as ReturnType<typeof vi.fn>
34-
3533
// Reset all mocks
3634
vi.clearAllMocks()
3735
})
@@ -58,7 +56,7 @@ describe('GDPR Email Utils', () => {
5856
requestType: 'ACCESS',
5957
}
6058
)
61-
expect(mockResendSend).not.toHaveBeenCalled()
59+
expect(mockSend).not.toHaveBeenCalled()
6260
})
6361

6462
it('should log email details instead of sending in test mode', async () => {
@@ -74,7 +72,7 @@ describe('GDPR Email Utils', () => {
7472
requestType: 'DELETE',
7573
}
7674
)
77-
expect(mockResendSend).not.toHaveBeenCalled()
75+
expect(mockSend).not.toHaveBeenCalled()
7876
})
7977

8078
it('should log email details instead of sending in CI mode', async () => {
@@ -91,7 +89,7 @@ describe('GDPR Email Utils', () => {
9189
requestType: 'ACCESS',
9290
}
9391
)
94-
expect(mockResendSend).not.toHaveBeenCalled()
92+
expect(mockSend).not.toHaveBeenCalled()
9593
})
9694
})
9795

@@ -104,15 +102,15 @@ describe('GDPR Email Utils', () => {
104102
})
105103

106104
it('should send ACCESS verification email successfully', async () => {
107-
mockResendSend.mockResolvedValue({
105+
mockSend.mockResolvedValue({
108106
data: { id: 'message-id-123' },
109107
error: null,
110108
})
111109

112110
await sendDSARVerificationEmail('user@example.com', 'verification-token-123', 'ACCESS')
113111

114-
expect(mockResendSend).toHaveBeenCalledTimes(1)
115-
const callArgs = mockResendSend.mock.calls[0]?.[0]
112+
expect(mockSend).toHaveBeenCalledTimes(1)
113+
const callArgs = mockSend.mock.calls[0]?.[0]
116114
expect(callArgs).toBeDefined()
117115

118116
expect(callArgs!.from).toBe('Webstack Builders <privacy@webstackbuilders.com>')
@@ -138,15 +136,15 @@ describe('GDPR Email Utils', () => {
138136
})
139137

140138
it('should send DELETE verification email successfully with warning', async () => {
141-
mockResendSend.mockResolvedValue({
139+
mockSend.mockResolvedValue({
142140
data: { id: 'message-id-456' },
143141
error: null,
144142
})
145143

146144
await sendDSARVerificationEmail('user@example.com', 'delete-token-456', 'DELETE')
147145

148-
expect(mockResendSend).toHaveBeenCalledTimes(1)
149-
const callArgs = mockResendSend.mock.calls[0]?.[0]
146+
expect(mockSend).toHaveBeenCalledTimes(1)
147+
const callArgs = mockSend.mock.calls[0]?.[0]
150148
expect(callArgs).toBeDefined()
151149

152150
expect(callArgs!.subject).toBe('Verify Your Data Deletion Request - Webstack Builders')
@@ -163,14 +161,14 @@ describe('GDPR Email Utils', () => {
163161

164162
it('should use default localhost URL when SITE_URL is not set', async () => {
165163
delete process.env['SITE_URL']
166-
mockResendSend.mockResolvedValue({
164+
mockSend.mockResolvedValue({
167165
data: { id: 'message-id-789' },
168166
error: null,
169167
})
170168

171169
await sendDSARVerificationEmail('user@example.com', 'token-789', 'ACCESS')
172170

173-
const callArgs = mockResendSend.mock.calls[0]?.[0]
171+
const callArgs = mockSend.mock.calls[0]?.[0]
174172
expect(callArgs).toBeDefined()
175173
expect(callArgs!.html).toContain('http://localhost:4321/api/gdpr/verify?token=token-789')
176174
expect(callArgs!.text).toContain('http://localhost:4321/api/gdpr/verify?token=token-789')
@@ -183,11 +181,11 @@ describe('GDPR Email Utils', () => {
183181
sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS')
184182
).rejects.toThrow('RESEND_API_KEY environment variable is not set')
185183

186-
expect(mockResendSend).not.toHaveBeenCalled()
184+
expect(mockSend).not.toHaveBeenCalled()
187185
})
188186

189187
it('should handle Resend API error response', async () => {
190-
mockResendSend.mockResolvedValue({
188+
mockSend.mockResolvedValue({
191189
data: null,
192190
error: {
193191
message: 'Invalid API key',
@@ -210,7 +208,7 @@ describe('GDPR Email Utils', () => {
210208

211209
it('should handle Resend API network error', async () => {
212210
const networkError = new Error('Network failure')
213-
mockResendSend.mockRejectedValue(networkError)
211+
mockSend.mockRejectedValue(networkError)
214212

215213
await expect(
216214
sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS')
@@ -224,29 +222,29 @@ describe('GDPR Email Utils', () => {
224222

225223
it('should include current year in email content', async () => {
226224
const currentYear = new Date().getFullYear()
227-
mockResendSend.mockResolvedValue({
225+
mockSend.mockResolvedValue({
228226
data: { id: 'message-id-year' },
229227
error: null,
230228
})
231229

232230
await sendDSARVerificationEmail('user@example.com', 'token-year', 'ACCESS')
233231

234-
const callArgs = mockResendSend.mock.calls[0]?.[0]
232+
const callArgs = mockSend.mock.calls[0]?.[0]
235233
expect(callArgs).toBeDefined()
236234
expect(callArgs!.html).toContain(`© ${currentYear} Webstack Builders`)
237235
expect(callArgs!.text).toContain(`© ${currentYear} Webstack Builders`)
238236
})
239237

240238
it('should generate proper verification URLs with tokens', async () => {
241239
process.env['SITE_URL'] = 'https://example.com'
242-
mockResendSend.mockResolvedValue({
240+
mockSend.mockResolvedValue({
243241
data: { id: 'message-id-url' },
244242
error: null,
245243
})
246244

247245
await sendDSARVerificationEmail('user@example.com', 'special-token-123', 'DELETE')
248246

249-
const callArgs = mockResendSend.mock.calls[0]?.[0]
247+
const callArgs = mockSend.mock.calls[0]?.[0]
250248
expect(callArgs).toBeDefined()
251249
const expectedUrl = 'https://example.com/api/gdpr/verify?token=special-token-123'
252250

‎src/pages/api/_utils/__tests__/rateLimit.spec.ts‎

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,59 @@
11
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2-
import { rateLimiters, checkRateLimit } from '@pages/api/_utils/rateLimit'
2+
import { rateLimiters, checkRateLimit, checkContactRateLimit } from '@pages/api/_utils/rateLimit'
33

44
// Mock the Upstash Redis and Ratelimit modules
55
vi.mock('@upstash/redis', () => ({
6-
Redis: vi.fn().mockImplementation(() => ({})),
6+
Redis: vi.fn(function RedisMock(_config) {
7+
return {}
8+
}),
79
}))
810

9-
vi.mock('@upstash/ratelimit', () => ({
10-
Ratelimit: vi.fn().mockImplementation((config) => ({
11-
limit: vi.fn(),
12-
config,
13-
})),
14-
}))
11+
vi.mock('@upstash/ratelimit', () => {
12+
const mockLimitFn = vi.fn()
13+
14+
const RatelimitConstructor = vi.fn(function RatelimitMock(config) {
15+
return {
16+
limit: mockLimitFn,
17+
config,
18+
}
19+
})
20+
21+
// Add static methods to the constructor
22+
Object.assign(RatelimitConstructor, {
23+
slidingWindow: vi.fn((requests, window) => ({ requests, window })),
24+
fixedWindow: vi.fn((requests, window) => ({ requests, window })),
25+
})
26+
27+
return {
28+
Ratelimit: RatelimitConstructor,
29+
}
30+
})
1531

1632
describe('Rate Limit Utils', () => {
1733
let originalEnv: Record<string, string | undefined>
34+
35+
// Get reference to the mock function after module initialization
1836
let mockLimit: ReturnType<typeof vi.fn>
1937

20-
beforeEach(() => {
38+
beforeEach(async () => {
2139
// Store original env
2240
originalEnv = { ...import.meta.env }
2341

2442
// Set test environment variables
2543
import.meta.env['KV_REST_API_URL'] = 'https://test-redis.upstash.io'
2644
import.meta.env['KV_REST_API_TOKEN'] = 'test-token'
2745

28-
// Get the mocked limit function
29-
mockLimit = vi.fn()
46+
// Get the mock function from the created rate limiters
47+
const { Ratelimit } = await import('@upstash/ratelimit')
48+
const { Redis } = await import('@upstash/redis')
49+
const rateLimiterInstance = new Ratelimit({
50+
redis: new Redis({
51+
url: 'https://test-redis.upstash.io',
52+
token: 'test-token',
53+
}),
54+
limiter: Ratelimit.slidingWindow(1, '1 m'),
55+
})
56+
mockLimit = rateLimiterInstance.limit as ReturnType<typeof vi.fn>
3057

3158
// Reset all mocks
3259
vi.clearAllMocks()
@@ -57,12 +84,16 @@ describe('Rate Limit Utils', () => {
5784
expect(rateLimiters.delete).toBeDefined()
5885
expect(typeof rateLimiters.delete.limit).toBe('function')
5986
})
87+
88+
it('should export contact rate limiter', () => {
89+
expect(rateLimiters.contact).toBeDefined()
90+
expect(typeof rateLimiters.contact.limit).toBe('function')
91+
})
6092
})
6193

6294
describe('checkRateLimit', () => {
6395
beforeEach(() => {
6496
// Create a mock rate limiter
65-
mockLimit = vi.fn()
6697
const mockRateLimiter = {
6798
limit: mockLimit,
6899
config: {},
@@ -212,4 +243,103 @@ describe('Rate Limit Utils', () => {
212243
expect(typeof result.reset).toBe('number')
213244
})
214245
})
246+
247+
describe('checkContactRateLimit', () => {
248+
let originalEnvMode: string | undefined
249+
let originalDev: boolean | undefined
250+
let originalCI: string | undefined
251+
252+
beforeEach(() => {
253+
originalEnvMode = import.meta.env.MODE
254+
originalDev = import.meta.env.DEV
255+
originalCI = process.env['CI']
256+
})
257+
258+
afterEach(() => {
259+
if (originalEnvMode !== undefined) {
260+
import.meta.env.MODE = originalEnvMode
261+
}
262+
if (originalDev !== undefined) {
263+
import.meta.env.DEV = originalDev
264+
}
265+
if (originalCI !== undefined) {
266+
process.env['CI'] = originalCI
267+
}
268+
})
269+
270+
it('should return true in test environment', () => {
271+
import.meta.env.MODE = 'test'
272+
273+
const result = checkContactRateLimit('192.168.1.1')
274+
expect(result).toBe(true)
275+
})
276+
277+
it('should return true in development environment', () => {
278+
import.meta.env.DEV = true
279+
280+
const result = checkContactRateLimit('192.168.1.1')
281+
expect(result).toBe(true)
282+
})
283+
284+
it('should return true in CI environment', () => {
285+
process.env['CI'] = 'true'
286+
287+
const result = checkContactRateLimit('192.168.1.1')
288+
expect(result).toBe(true)
289+
})
290+
291+
it('should allow requests under the limit in production', () => {
292+
// Set production-like environment
293+
import.meta.env.MODE = 'production'
294+
import.meta.env.DEV = false
295+
process.env['CI'] = 'false'
296+
297+
const ip = '192.168.1.2'
298+
299+
// First 5 requests should succeed
300+
for (let i = 0; i < 5; i++) {
301+
const result = checkContactRateLimit(ip)
302+
expect(result).toBe(true)
303+
}
304+
})
305+
306+
it('should block requests over the limit in production', () => {
307+
// Set production-like environment
308+
import.meta.env.MODE = 'production'
309+
import.meta.env.DEV = false
310+
process.env['CI'] = 'false'
311+
312+
const ip = '192.168.1.3'
313+
314+
// Use up the limit (5 requests)
315+
for (let i = 0; i < 5; i++) {
316+
checkContactRateLimit(ip)
317+
}
318+
319+
// 6th request should be blocked
320+
const result = checkContactRateLimit(ip)
321+
expect(result).toBe(false)
322+
})
323+
324+
it('should isolate rate limits by IP address', () => {
325+
// Set production-like environment
326+
import.meta.env.MODE = 'production'
327+
import.meta.env.DEV = false
328+
process.env['CI'] = 'false'
329+
330+
const ip1 = '192.168.1.4'
331+
const ip2 = '192.168.1.5'
332+
333+
// Use up limit for first IP
334+
for (let i = 0; i < 5; i++) {
335+
checkContactRateLimit(ip1)
336+
}
337+
338+
// First IP should be blocked
339+
expect(checkContactRateLimit(ip1)).toBe(false)
340+
341+
// Second IP should still work
342+
expect(checkContactRateLimit(ip2)).toBe(true)
343+
})
344+
})
215345
})

0 commit comments

Comments
 (0)