diff --git a/services/platform-service/src/modules/promos/routes.test.ts b/services/platform-service/src/modules/promos/routes.test.ts new file mode 100644 index 00000000..06d49ca9 --- /dev/null +++ b/services/platform-service/src/modules/promos/routes.test.ts @@ -0,0 +1,158 @@ +/** + * Route-level tests for promos module — Fastify inject with mocked Stripe. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const stripeMock = { + promotionCodes: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + coupons: { + create: vi.fn(), + }, +}; + +vi.mock('stripe', () => ({ + default: vi.fn().mockImplementation(() => stripeMock), +})); + +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +function buildPromo(overrides?: Partial>) { + return { + id: 'promo_1', + code: 'SAVE20', + active: true, + coupon: { + id: 'coupon_1', + percent_off: 20, + amount_off: null, + currency: 'usd', + duration: 'once', + }, + times_redeemed: 0, + max_redemptions: null, + expires_at: null, + created: 1708000000, + metadata: { createdBy: 'admin', productId: 'lysnrai' }, + ...overrides, + }; +} + +async function buildApp() { + const { promoRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(promoRoutes, { prefix: '/api' }); + return app; +} + +describe('promoRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.STRIPE_SECRET_KEY = 'sk_test_123'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /promos lists promo codes', async () => { + stripeMock.promotionCodes.list.mockResolvedValue({ data: [buildPromo()] }); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/promos?active=true' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.promos).toHaveLength(1); + expect(data.promos[0].code).toBe('SAVE20'); + }); + + it('POST /promos returns 400 when discount fields are missing', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/promos', + payload: { code: 'bad', createdBy: 'admin' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('POST /promos creates coupon and promotion code', async () => { + stripeMock.coupons.create.mockResolvedValue({ id: 'coupon_1' }); + stripeMock.promotionCodes.create.mockResolvedValue(buildPromo()); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/promos', + payload: { + code: 'save-20!', + percentOff: 20, + duration: 'once', + createdBy: 'admin', + }, + }); + + expect(res.statusCode).toBe(201); + const data = JSON.parse(res.body); + expect(data.code).toBe('SAVE20'); + expect(stripeMock.promotionCodes.create).toHaveBeenCalled(); + }); + + it('POST /promos/validate returns 400 without code', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/promos/validate', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + it('POST /promos/validate returns invalid when no active promo', async () => { + stripeMock.promotionCodes.list.mockResolvedValue({ data: [] }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/promos/validate', + payload: { code: 'NOPE' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.valid).toBe(false); + expect(data.promo).toBeNull(); + }); + + it('PUT /promos/:id/deactivate deactivates promo', async () => { + stripeMock.promotionCodes.update.mockResolvedValue(buildPromo({ active: false })); + const app = await buildApp(); + + const res = await app.inject({ method: 'PUT', url: '/api/promos/promo_1/deactivate' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.active).toBe(false); + }); + + it('PUT /promos/:id/deactivate returns 400 on Stripe error', async () => { + stripeMock.promotionCodes.update.mockRejectedValue(new Error('stripe failure')); + const app = await buildApp(); + + const res = await app.inject({ method: 'PUT', url: '/api/promos/promo_bad/deactivate' }); + + expect(res.statusCode).toBe(400); + }); +});