diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..cb6d4e4 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "next/core-web-vitals" + ] +} diff --git a/app/api/cart/promo-code/route.ts b/app/api/cart/promo-code/route.ts new file mode 100644 index 0000000..89bf389 --- /dev/null +++ b/app/api/cart/promo-code/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { cartItemSchema, resolvePromoCodePercent, totalize } from '../../../../lib/cart'; + +const PromoCodeApplySchema = z.object({ + items: z.array(cartItemSchema).min(1), + promoCode: z + .string() + .trim() + .min(1) + .max(32) + .regex(/^[A-Za-z0-9_-]+$/), + region: z.string().trim().min(2).max(8), +}); + +function authorize(req: NextRequest): NextResponse | null { + const authorization = req.headers.get('authorization'); + const userId = req.headers.get('x-user-id'); + const role = req.headers.get('x-user-role'); + + if (!authorization || !authorization.startsWith('Bearer ') || !userId) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + if (role !== 'customer') { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }); + } + + return null; +} + +export async function POST(req: NextRequest) { + const authFailure = authorize(req); + if (authFailure) { + return authFailure; + } + + const body = await req.json(); + const parsed = PromoCodeApplySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }); + } + + const subtotalCents = parsed.data.items.reduce( + (sum, item) => sum + item.unitPriceCents * item.quantity, + 0, + ); + const discountPercent = resolvePromoCodePercent(subtotalCents, parsed.data.promoCode); + if (discountPercent <= 0) { + return NextResponse.json({ error: 'invalid_or_inapplicable_promo_code' }, { status: 400 }); + } + + const totals = totalize(parsed.data.items, parsed.data.promoCode, parsed.data.region); + return NextResponse.json({ + promoCode: parsed.data.promoCode.toUpperCase(), + discountPercent, + totals, + }); +} diff --git a/lib/cart.ts b/lib/cart.ts index 4d835f5..d3fe0c3 100644 --- a/lib/cart.ts +++ b/lib/cart.ts @@ -15,6 +15,11 @@ export interface CartTotals { totalCents: number; } +const PROMO_CODE_PERCENTAGES: Record = { + WELCOME10: 10, + VIP25: 25, +}; + export function addItem(cart: CartItem[], item: CartItem): CartItem[] { const existing = cart.findIndex((c) => c.productId === item.productId); if (existing === -1) return [...cart, item]; @@ -27,13 +32,22 @@ export function removeItem(cart: CartItem[], productId: string): CartItem[] { return cart.filter((c) => c.productId !== productId); } -export function applyDiscount(subtotalCents: number, code: string | null): number { +export function resolvePromoCodePercent(subtotalCents: number, code: string | null): number { if (!code) return 0; - const upper = code.toUpperCase(); - if (upper === 'WELCOME10') return Math.floor(subtotalCents * 0.10); - if (upper === 'VIP25' && subtotalCents >= 10_000) return Math.floor(subtotalCents * 0.25); - if (upper === 'FREESHIP') return 0; - return 0; + const normalized = code.toUpperCase().trim(); + if (normalized === 'VIP25' && subtotalCents < 10_000) return 0; + return PROMO_CODE_PERCENTAGES[normalized] ?? 0; +} + +export function applyPercentageDiscount(subtotalCents: number, percent: number): number { + if (!Number.isFinite(percent)) return 0; + const boundedPercent = Math.max(0, Math.min(100, percent)); + return Math.floor(subtotalCents * (boundedPercent / 100)); +} + +export function applyDiscount(subtotalCents: number, code: string | null): number { + const percent = resolvePromoCodePercent(subtotalCents, code); + return applyPercentageDiscount(subtotalCents, percent); } export function computeTax(taxableCents: number, region: string): number { diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/tests/cart.test.ts b/tests/cart.test.ts index 214028f..aecf4dc 100644 --- a/tests/cart.test.ts +++ b/tests/cart.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { addItem, removeItem, totalize } from '../lib/cart'; +import { addItem, applyPercentageDiscount, removeItem, resolvePromoCodePercent, totalize } from '../lib/cart'; describe('cart · happy path', () => { it('adds new items', () => { @@ -29,6 +29,19 @@ describe('cart · happy path', () => { expect(totals.taxCents).toBe(400); expect(totals.totalCents).toBe(2400); }); + + it('resolves promo code percentage with threshold checks', () => { + expect(resolvePromoCodePercent(5_000, 'WELCOME10')).toBe(10); + expect(resolvePromoCodePercent(9_999, 'VIP25')).toBe(0); + expect(resolvePromoCodePercent(10_000, 'VIP25')).toBe(25); + expect(resolvePromoCodePercent(5_000, 'UNKNOWN')).toBe(0); + }); + + it('applies percentage discounts with clamping', () => { + expect(applyPercentageDiscount(10_000, 10)).toBe(1_000); + expect(applyPercentageDiscount(10_000, 500)).toBe(10_000); + expect(applyPercentageDiscount(10_000, -5)).toBe(0); + }); }); // NOTE (workshop): the following branches have NO tests yet — Track 1 (test-improver) diff --git a/tests/promo-code-route.test.ts b/tests/promo-code-route.test.ts new file mode 100644 index 0000000..ac3ff44 --- /dev/null +++ b/tests/promo-code-route.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { NextRequest } from 'next/server'; +import { POST } from '../app/api/cart/promo-code/route'; + +function buildRequest( + body: unknown, + headers: Record = {}, +): NextRequest { + return new NextRequest('http://localhost:3000/api/cart/promo-code', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/cart/promo-code', () => { + it('returns unauthorized when auth headers are missing', async () => { + const response = await POST( + buildRequest({ + items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }], + promoCode: 'WELCOME10', + region: 'GB', + }), + ); + + expect(response.status).toBe(401); + }); + + it('returns forbidden when role is not customer', async () => { + const response = await POST( + buildRequest( + { + items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }], + promoCode: 'WELCOME10', + region: 'GB', + }, + { + authorization: 'Bearer test-token', + 'x-user-id': 'u1', + 'x-user-role': 'admin', + }, + ), + ); + + expect(response.status).toBe(403); + }); + + it('returns bad request for an invalid or inapplicable code', async () => { + const response = await POST( + buildRequest( + { + items: [{ productId: 'p1', quantity: 1, unitPriceCents: 1_000 }], + promoCode: 'VIP25', + region: 'GB', + }, + { + authorization: 'Bearer test-token', + 'x-user-id': 'u1', + 'x-user-role': 'customer', + }, + ), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_or_inapplicable_promo_code', + }); + }); + + it('applies percentage discount and returns totals', async () => { + const response = await POST( + buildRequest( + { + items: [{ productId: 'p1', quantity: 1, unitPriceCents: 10_000 }], + promoCode: 'vip25', + region: 'GB', + }, + { + authorization: 'Bearer test-token', + 'x-user-id': 'u1', + 'x-user-role': 'customer', + }, + ), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + promoCode: 'VIP25', + discountPercent: 25, + totals: { + subtotalCents: 10_000, + discountCents: 2_500, + taxCents: 1_500, + totalCents: 9_000, + }, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 0146e9b..0c2ec95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "module": "esnext", "moduleResolution": "bundler", "jsx": "preserve", @@ -16,9 +20,23 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "security-fixtures"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "security-fixtures" + ] }