Skip to content

Commit ea4c479

Browse files
committed
up
1 parent dd633d3 commit ea4c479

3 files changed

Lines changed: 63 additions & 58 deletions

File tree

specs/002-harden-checkout-tenancy/checklists/api.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@ Author: automated speckit.checklist run
1515
Notes: This checklist tests the *requirements text* (not the implementation). Each item references the spec where relevant (e.g., FR identifiers). If a requirement is missing or ambiguous, mark the item and update spec/plan/tasks accordingly.
1616

1717
## Requirement Completeness
18-
- [ ] CHK001 - Are authentication requirements for checkout fully specified, including which endpoints require a session and exact auth failure responses? [Completeness, Spec §FR-001]
19-
- [ ] CHK002 - Is the server-side pricing recalculation scope fully enumerated (line items, discounts, shipping, taxes, rounding rules, currency behavior)? [Completeness, Spec §FR-002]
20-
- [ ] CHK003 - Are all API endpoints that must include `X-Request-Id` and correlation propagation identified in the spec/plan? [Completeness, Spec §FR-021]
21-
- [ ] CHK004 - Are CSV export behaviors fully specified (stream threshold, async enqueue conditions, notification channels) and linked to an export failure/retry policy? [Completeness, Spec §FR-016]
18+
- [X] CHK001 - Are authentication requirements for checkout fully specified, including which endpoints require a session and exact auth failure responses? [Completeness, Spec §FR-001]
19+
- ✅ PASS: FR-001 requires valid signed-in session; contracts/openapi.yaml defines 401 Unauthorized response; tasks T1.1 specifies getServerSession() usage
20+
- [X] CHK002 - Is the server-side pricing recalculation scope fully enumerated (line items, discounts, shipping, taxes, rounding rules, currency behavior)? [Completeness, Spec §FR-002]
21+
- ✅ PASS: FR-002 explicitly lists "all line items, discounts, shipping, and taxes"; data-model.md shows Order.totals structure; tasks T1.2 references validateCart and total calculation
22+
- [X] CHK003 - Are all API endpoints that must include `X-Request-Id` and correlation propagation identified in the spec/plan? [Completeness, Spec §FR-021]
23+
- ✅ PASS: FR-021 mandates unique request/correlation ID in all API responses; plan T0.1-T0.2 creates request-context.ts; tasks T4.2 adds requestId seeding in proxy
24+
- [X] CHK004 - Are CSV export behaviors fully specified (stream threshold, async enqueue conditions, notification channels) and linked to an export failure/retry policy? [Completeness, Spec §FR-016]
25+
- ✅ PASS: FR-016 specifies ≤10k rows streaming with memory/time caps, >10k async job with email + in-app notification; clarifications specify 200MB heap/120s timeout; contracts/openapi.yaml shows 200 vs 202 responses
2226

2327
## Requirement Clarity
24-
- [ ] CHK005 - Is the payment pre-validation requirement precise about what constitutes a valid intent/token (authorization vs capture) and when validation occurs? [Clarity, Spec §FR-003]
25-
- [ ] CHK006 - Are error response shapes and HTTP status mappings explicitly defined for common failures (validation, auth, rate-limit, conflict, internal)? [Clarity, Spec §FR-008]
26-
- [ ] CHK007 - Is the canonical redirect behavior specified with HTTP status code, canonical link header conventions, and SEO considerations (308 vs 301, rel=canonical)? [Clarity, Spec §FR-005]
27-
- [ ] CHK008 - Are rate-limiting thresholds for newsletter and checkout clearly quantified (requests/min per IP/store) and behavior on exceed (Retry-After header) defined? [Clarity, Spec §FR-019, assumption in spec]
28+
- [X] CHK005 - Is the payment pre-validation requirement precise about what constitutes a valid intent/token (authorization vs capture) and when validation occurs? [Clarity, Spec §FR-003]
29+
- ✅ PASS: FR-003 clarification states "verify payment intent exists, amount/currency match server totals, accept AUTHORIZED states for later capture, document which states are validated vs captured"; clarifications add idempotency key handling and retry semantics
30+
- [X] CHK006 - Are error response shapes and HTTP status mappings explicitly defined for common failures (validation, auth, rate-limit, conflict, internal)? [Clarity, Spec §FR-008]
31+
- ✅ PASS: FR-008 standardizes success `{data, meta?, message?}` and error `{error: {code, message, details?}}`; contracts/openapi.yaml defines 400/401/409/429/500 responses with Error schema; plan T0.3 creates error class hierarchy with httpStatus
32+
- [X] CHK007 - Is the canonical redirect behavior specified with HTTP status code, canonical link header conventions, and SEO considerations (308 vs 301, rel=canonical)? [Clarity, Spec §FR-005]
33+
- ✅ PASS: FR-005 clarification specifies HTTP 301 for GET requests with `Link: <https://{primary-domain}{path}>; rel="canonical"` header; for non-GET returns 4xx with explanatory body; tasks T2.1 implements in proxy.ts
34+
- [X] CHK008 - Are rate-limiting thresholds for newsletter and checkout clearly quantified (requests/min per IP/store) and behavior on exceed (Retry-After header) defined? [Clarity, Spec §FR-019, assumption in spec]
35+
- ✅ PASS: Assumptions section states "100 requests per minute per IP"; contracts/openapi.yaml shows 429 RateLimited response; tasks T3.1 calls simple limiter (100 rpm/IP); constitution references rate-limit.ts implementation
2836

2937
## Requirement Consistency
3038
- [ ] CHK009 - Do authentication requirements align across spec/plan/tasks (no conflicting statements about anonymous checkout or default store fallbacks)? [Consistency, Spec §FR-001/FR-006]

tests/unit/newsletter/newsletter-actions.test.ts

Lines changed: 46 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,10 @@ import { NewsletterService } from '@/services/newsletter-service';
3232
import { checkSimpleRateLimit } from '@/lib/simple-rate-limit';
3333
import { headers } from 'next/headers';
3434

35-
// Mock simple rate limit types
36-
type SimpleRateLimitResult = {
37-
success: boolean;
38-
limit: number;
39-
remaining: number;
40-
reset: number;
41-
} | null;
35+
// Mock external dependencies
36+
vi.mock('@/services/newsletter-service');
37+
vi.mock('@/lib/simple-rate-limit');
38+
vi.mock('next/headers');
4239

4340
describe('Newsletter Server Actions', () => {
4441
beforeEach(() => {
@@ -66,7 +63,7 @@ describe('Newsletter Server Actions', () => {
6663
};
6764

6865
// Mock rate limit check (not exceeded)
69-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
66+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
7067

7168
// Mock successful subscription
7269
vi.mocked(NewsletterService.subscribe).mockResolvedValue({
@@ -79,7 +76,7 @@ describe('Newsletter Server Actions', () => {
7976
formData.append('email', 'user@example.com');
8077
formData.append('storeId', 'store-123');
8178

82-
const result = await subscribeToNewsletter(formData);
79+
const result = await subscribeToNewsletter(null, formData);
8380

8481
expect(result).toEqual({
8582
success: true,
@@ -110,7 +107,7 @@ describe('Newsletter Server Actions', () => {
110107
updatedAt: new Date(),
111108
};
112109

113-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
110+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
114111

115112
vi.mocked(NewsletterService.subscribe).mockResolvedValue({
116113
subscription: mockNewsletter,
@@ -122,7 +119,7 @@ describe('Newsletter Server Actions', () => {
122119
formData.append('email', 'user@example.com');
123120
formData.append('storeId', 'store-123');
124121

125-
const result = await subscribeToNewsletter(formData);
122+
const result = await subscribeToNewsletter(null, formData);
126123

127124
expect(result).toEqual({
128125
success: true,
@@ -132,18 +129,18 @@ describe('Newsletter Server Actions', () => {
132129
});
133130

134131
it('should enforce rate limiting', async () => {
135-
const mockRateLimitResponse = new Response(
136-
JSON.stringify({ error: 'Too many requests' }),
137-
{ status: 429 }
138-
);
139-
140-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(mockRateLimitResponse);
132+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({
133+
success: false,
134+
limit: 100,
135+
remaining: 0,
136+
reset: Date.now() + 60000,
137+
});
141138

142139
const formData = new FormData();
143140
formData.append('email', 'user@example.com');
144141
formData.append('storeId', 'store-123');
145142

146-
const result = await subscribeToNewsletter(formData);
143+
const result = await subscribeToNewsletter(null, formData);
147144

148145
expect(result).toEqual({
149146
success: false,
@@ -154,71 +151,71 @@ describe('Newsletter Server Actions', () => {
154151
});
155152

156153
it('should validate email format with Zod', async () => {
157-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
154+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
158155

159156
const formData = new FormData();
160157
formData.append('email', 'invalid-email'); // Invalid format
161158
formData.append('storeId', 'store-123');
162159

163-
const result = await subscribeToNewsletter(formData);
160+
const result = await subscribeToNewsletter(null, formData);
164161

165162
expect(result.success).toBe(false);
166163
expect(result.error).toContain('Invalid email');
167164
expect(NewsletterService.subscribe).not.toHaveBeenCalled();
168165
});
169166

170167
it('should validate email length (min 3 chars)', async () => {
171-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
168+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
172169

173170
const formData = new FormData();
174171
formData.append('email', 'ab'); // Too short
175172
formData.append('storeId', 'store-123');
176173

177-
const result = await subscribeToNewsletter(formData);
174+
const result = await subscribeToNewsletter(null, formData);
178175

179176
expect(result.success).toBe(false);
180177
expect(result.error).toBeDefined();
181178
expect(NewsletterService.subscribe).not.toHaveBeenCalled();
182179
});
183180

184181
it('should validate email length (max 255 chars)', async () => {
185-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
182+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
186183

187184
const longEmail = 'a'.repeat(250) + '@example.com'; // > 255 chars
188185

189186
const formData = new FormData();
190187
formData.append('email', longEmail);
191188
formData.append('storeId', 'store-123');
192189

193-
const result = await subscribeToNewsletter(formData);
190+
const result = await subscribeToNewsletter(null, formData);
194191

195192
expect(result.success).toBe(false);
196193
expect(result.error).toBeDefined();
197194
expect(NewsletterService.subscribe).not.toHaveBeenCalled();
198195
});
199196

200197
it('should require email field', async () => {
201-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
198+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
202199

203200
const formData = new FormData();
204201
formData.append('storeId', 'store-123');
205202
// Missing email
206203

207-
const result = await subscribeToNewsletter(formData);
204+
const result = await subscribeToNewsletter(null, formData);
208205

209206
expect(result.success).toBe(false);
210207
expect(result.error).toBeDefined();
211208
expect(NewsletterService.subscribe).not.toHaveBeenCalled();
212209
});
213210

214211
it('should require storeId field', async () => {
215-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
212+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
216213

217214
const formData = new FormData();
218215
formData.append('email', 'user@example.com');
219216
// Missing storeId
220217

221-
const result = await subscribeToNewsletter(formData);
218+
const result = await subscribeToNewsletter(null, formData);
222219

223220
expect(result.success).toBe(false);
224221
expect(result.error).toBeDefined();
@@ -233,7 +230,7 @@ describe('Newsletter Server Actions', () => {
233230
});
234231

235232
vi.mocked(headers).mockResolvedValue(headersWithDNT);
236-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
233+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
237234

238235
vi.mocked(NewsletterService.subscribe).mockResolvedValue({
239236
subscription: {} as Newsletter,
@@ -245,7 +242,7 @@ describe('Newsletter Server Actions', () => {
245242
formData.append('email', 'user@example.com');
246243
formData.append('storeId', 'store-123');
247244

248-
await subscribeToNewsletter(formData);
245+
await subscribeToNewsletter(null, formData);
249246

250247
expect(NewsletterService.subscribe).toHaveBeenCalledWith(
251248
expect.objectContaining({
@@ -257,7 +254,7 @@ describe('Newsletter Server Actions', () => {
257254
});
258255

259256
it('should handle service errors gracefully', async () => {
260-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
257+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
261258

262259
vi.mocked(NewsletterService.subscribe).mockRejectedValue(
263260
new Error('Database connection failed')
@@ -267,7 +264,7 @@ describe('Newsletter Server Actions', () => {
267264
formData.append('email', 'user@example.com');
268265
formData.append('storeId', 'store-123');
269266

270-
const result = await subscribeToNewsletter(formData);
267+
const result = await subscribeToNewsletter(null, formData);
271268

272269
expect(result).toEqual({
273270
success: false,
@@ -290,15 +287,15 @@ describe('Newsletter Server Actions', () => {
290287
updatedAt: new Date(),
291288
};
292289

293-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
290+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
294291

295292
vi.mocked(NewsletterService.unsubscribe).mockResolvedValue(mockUnsubscribed);
296293

297294
const formData = new FormData();
298295
formData.append('email', 'user@example.com');
299296
formData.append('storeId', 'store-123');
300297

301-
const result = await unsubscribeFromNewsletter(formData);
298+
const result = await unsubscribeFromNewsletter(null, formData);
302299

303300
expect(result).toEqual({
304301
success: true,
@@ -315,15 +312,15 @@ describe('Newsletter Server Actions', () => {
315312
});
316313

317314
it('should handle non-existent subscription', async () => {
318-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
315+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
319316

320317
vi.mocked(NewsletterService.unsubscribe).mockResolvedValue(null);
321318

322319
const formData = new FormData();
323320
formData.append('email', 'nonexistent@example.com');
324321
formData.append('storeId', 'store-123');
325322

326-
const result = await unsubscribeFromNewsletter(formData);
323+
const result = await unsubscribeFromNewsletter(null, formData);
327324

328325
expect(result).toEqual({
329326
success: true,
@@ -332,18 +329,18 @@ describe('Newsletter Server Actions', () => {
332329
});
333330

334331
it('should enforce rate limiting', async () => {
335-
const mockRateLimitResponse = new Response(
336-
JSON.stringify({ error: 'Too many requests' }),
337-
{ status: 429 }
338-
);
339-
340-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(mockRateLimitResponse);
332+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({
333+
success: false,
334+
limit: 100,
335+
remaining: 0,
336+
reset: Date.now() + 60000,
337+
});
341338

342339
const formData = new FormData();
343340
formData.append('email', 'user@example.com');
344341
formData.append('storeId', 'store-123');
345342

346-
const result = await unsubscribeFromNewsletter(formData);
343+
const result = await unsubscribeFromNewsletter(null, formData);
347344

348345
expect(result).toEqual({
349346
success: false,
@@ -354,35 +351,35 @@ describe('Newsletter Server Actions', () => {
354351
});
355352

356353
it('should require email field', async () => {
357-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
354+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
358355

359356
const formData = new FormData();
360357
formData.append('storeId', 'store-123');
361358
// Missing email
362359

363-
const result = await unsubscribeFromNewsletter(formData);
360+
const result = await unsubscribeFromNewsletter(null, formData);
364361

365362
expect(result.success).toBe(false);
366363
expect(result.error).toBe('Email is required');
367364
expect(NewsletterService.unsubscribe).not.toHaveBeenCalled();
368365
});
369366

370367
it('should require storeId field', async () => {
371-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
368+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
372369

373370
const formData = new FormData();
374371
formData.append('email', 'user@example.com');
375372
// Missing storeId
376373

377-
const result = await unsubscribeFromNewsletter(formData);
374+
const result = await unsubscribeFromNewsletter(null, formData);
378375

379376
expect(result.success).toBe(false);
380377
expect(result.error).toBe('Store ID is required');
381378
expect(NewsletterService.unsubscribe).not.toHaveBeenCalled();
382379
});
383380

384381
it('should handle service errors gracefully', async () => {
385-
vi.mocked(checkSimpleRateLimit).mockResolvedValue(null);
382+
vi.mocked(checkSimpleRateLimit).mockResolvedValue({ success: true, limit: 100, remaining: 99, reset: Date.now() + 60000 });
386383

387384
vi.mocked(NewsletterService.unsubscribe).mockRejectedValue(
388385
new Error('Database connection failed')
@@ -392,7 +389,7 @@ describe('Newsletter Server Actions', () => {
392389
formData.append('email', 'user@example.com');
393390
formData.append('storeId', 'store-123');
394391

395-
const result = await unsubscribeFromNewsletter(formData);
392+
const result = await unsubscribeFromNewsletter(null, formData);
396393

397394
expect(result).toEqual({
398395
success: false,

tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)