From 149d97dd9f68d354e0f1f49c233e8bc44cd9d504 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 16 Feb 2026 12:14:42 -0800 Subject: [PATCH] =?UTF-8?q?test(platform-service):=20add=20route-level=20t?= =?UTF-8?q?ests=20for=20plans=20+=20notifications=20=E2=80=94=20379=20test?= =?UTF-8?q?s,=2035.4%=20stmts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/notifications/routes.test.ts | 182 ++++++++++++++++++ .../src/modules/plans/routes.test.ts | 163 ++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 services/platform-service/src/modules/notifications/routes.test.ts create mode 100644 services/platform-service/src/modules/plans/routes.test.ts diff --git a/services/platform-service/src/modules/notifications/routes.test.ts b/services/platform-service/src/modules/notifications/routes.test.ts new file mode 100644 index 00000000..ff42928c --- /dev/null +++ b/services/platform-service/src/modules/notifications/routes.test.ts @@ -0,0 +1,182 @@ +/** + * Route-level tests for notifications module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + getDevicesByUser: vi.fn(), + upsertDevice: vi.fn(), + removeDevice: vi.fn(), + getPrefs: vi.fn(), + upsertPrefs: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const baseDevice = { + id: 'dev_user_1_device_abc', + productId: 'lysnrai', + userId: 'user_1', + deviceId: 'device_abc', + platform: 'ios', + lastSeenAt: '2026-02-16T00:00:00Z', + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +describe('notificationRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /devices registers a device', async () => { + repoMock.upsertDevice.mockResolvedValue(baseDevice); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/devices', + payload: { + userId: 'user_1', + deviceId: 'device_abc', + platform: 'ios', + }, + }); + expect(res.statusCode).toBe(200); + expect(repoMock.upsertDevice).toHaveBeenCalled(); + }); + + it('POST /devices rejects invalid platform', async () => { + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/devices', + payload: { + userId: 'user_1', + deviceId: 'device_abc', + platform: 'linux', + }, + }); + expect(res.statusCode).toBe(400); + }); + + it('GET /devices/:userId lists devices', async () => { + repoMock.getDevicesByUser.mockResolvedValue([baseDevice]); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/devices/user_1' }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.devices).toHaveLength(1); + }); + + it('DELETE /devices/:id removes a device', async () => { + repoMock.removeDevice.mockResolvedValue(true); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/devices/dev_1?userId=user_1', + }); + expect(res.statusCode).toBe(200); + }); + + it('DELETE /devices/:id returns 400 without userId', async () => { + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/devices/dev_1' }); + expect(res.statusCode).toBe(400); + }); + + it('DELETE /devices/:id returns 404 when not found', async () => { + repoMock.removeDevice.mockResolvedValue(false); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/devices/nonexistent?userId=user_1', + }); + expect(res.statusCode).toBe(404); + }); + + it('GET /notifications/prefs/:userId returns prefs', async () => { + repoMock.getPrefs.mockResolvedValue({ + pushEnabled: true, + emailEnabled: false, + categories: { marketing: false }, + }); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/notifications/prefs/user_1', + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pushEnabled).toBe(true); + }); + + it('GET /notifications/prefs/:userId returns defaults when no prefs', async () => { + repoMock.getPrefs.mockResolvedValue(null); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/notifications/prefs/user_1', + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pushEnabled).toBe(true); + expect(data.emailEnabled).toBe(true); + }); + + it('PUT /notifications/prefs/:userId updates prefs', async () => { + repoMock.getPrefs.mockResolvedValue(null); + repoMock.upsertPrefs.mockResolvedValue({ + id: 'prefs_lysnrai_user_1', + productId: 'lysnrai', + userId: 'user_1', + pushEnabled: false, + emailEnabled: true, + categories: {}, + }); + const { notificationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(notificationRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'PUT', + url: '/api/notifications/prefs/user_1', + payload: { pushEnabled: false }, + }); + expect(res.statusCode).toBe(200); + expect(repoMock.upsertPrefs).toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/plans/routes.test.ts b/services/platform-service/src/modules/plans/routes.test.ts new file mode 100644 index 00000000..3dc4e8ca --- /dev/null +++ b/services/platform-service/src/modules/plans/routes.test.ts @@ -0,0 +1,163 @@ +/** + * Route-level tests for plans module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + list: vi.fn(), + getByName: vi.fn(), + create: vi.fn(), + update: vi.fn(), + getDefaults: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const basePlan = { + id: 'plan_lysnrai_pro', + productId: 'lysnrai', + name: 'pro', + displayName: 'Pro', + price: 9.99, + tokens: 100000, + words: 50000, + dictations: 5000, + features: ['basic_dictation'], + active: true, + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +describe('planRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /plans returns plan list', async () => { + repoMock.list.mockResolvedValue([basePlan]); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/plans' }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.plans).toHaveLength(1); + expect(data.plans[0].name).toBe('pro'); + }); + + it('GET /plans/:name returns plan by name', async () => { + repoMock.getByName.mockResolvedValue(basePlan); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/plans/pro' }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.name).toBe('pro'); + }); + + it('GET /plans/:name falls back to defaults', async () => { + repoMock.getByName.mockResolvedValue(null); + repoMock.getDefaults.mockReturnValue([ + { ...basePlan, name: 'free', displayName: 'Free' }, + ]); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/plans/free' }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.name).toBe('free'); + }); + + it('POST /plans creates a plan', async () => { + repoMock.create.mockResolvedValue(basePlan); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/plans', + payload: { + name: 'pro', + displayName: 'Pro', + price: 9.99, + tokens: 100000, + words: 50000, + dictations: 5000, + }, + }); + expect(res.statusCode).toBe(201); + }); + + it('POST /plans rejects invalid input', async () => { + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/plans', + payload: { price: -1 }, + }); + expect(res.statusCode).toBe(400); + }); + + it('PUT /plans/:id updates a plan', async () => { + repoMock.update.mockResolvedValue({ ...basePlan, price: 14.99 }); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'PUT', + url: '/api/plans/plan_lysnrai_pro', + payload: { price: 14.99 }, + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.price).toBe(14.99); + }); + + it('PUT /plans/:id returns 404 when not found', async () => { + repoMock.update.mockResolvedValue(null); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'PUT', + url: '/api/plans/nonexistent', + payload: { price: 14.99 }, + }); + expect(res.statusCode).toBe(404); + }); + + it('POST /plans/seed seeds default plans', async () => { + repoMock.getDefaults.mockReturnValue([basePlan]); + repoMock.getByName.mockResolvedValue(null); + repoMock.create.mockResolvedValue(basePlan); + const { planRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(planRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'POST', url: '/api/plans/seed' }); + expect(res.statusCode).toBe(201); + const data = JSON.parse(res.body); + expect(data.plans).toHaveLength(1); + }); +});