diff --git a/services/platform-service/src/modules/audit/routes.test.ts b/services/platform-service/src/modules/audit/routes.test.ts new file mode 100644 index 00000000..97ec5e4f --- /dev/null +++ b/services/platform-service/src/modules/audit/routes.test.ts @@ -0,0 +1,101 @@ +/** + * Route-level tests for audit module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + create: vi.fn(), + query: vi.fn(), + getStats: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +async function buildApp() { + const { auditRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(auditRoutes, { prefix: '/api' }); + return app; +} + +describe('auditRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /audit accepts valid payload (fire-and-forget)', async () => { + repoMock.create.mockResolvedValue(undefined); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/audit', + payload: { + userId: 'user_1', + action: 'item.created', + category: 'tracker', + details: { itemId: 'trk_1' }, + }, + }); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body).accepted).toBe(true); + expect(repoMock.create).toHaveBeenCalled(); + }); + + it('POST /audit returns 400 on invalid payload', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/audit', + payload: { action: '' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('GET /audit returns records', async () => { + repoMock.query.mockResolvedValue([{ id: 'aud_1', action: 'item.created' }]); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/audit?userId=user_1&days=7&limit=10&offset=0', + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.records).toHaveLength(1); + expect(data.count).toBe(1); + }); + + it('GET /audit returns 400 for invalid query', async () => { + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/audit?days=0' }); + + expect(res.statusCode).toBe(400); + }); + + it('GET /audit/stats returns stats for given days', async () => { + repoMock.getStats.mockResolvedValue({ item_created: 3, login: 5 }); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/audit/stats?days=14' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.days).toBe(14); + expect(data.stats.item_created).toBe(3); + }); +}); diff --git a/services/platform-service/src/modules/invitations/routes.test.ts b/services/platform-service/src/modules/invitations/routes.test.ts new file mode 100644 index 00000000..494fed26 --- /dev/null +++ b/services/platform-service/src/modules/invitations/routes.test.ts @@ -0,0 +1,179 @@ +/** + * Route-level tests for invitations module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + list: vi.fn(), + count: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + redeem: vi.fn(), +}; + +const webhookMock = { + dispatchInvitationRedeemed: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('../../lib/webhooks.js', () => webhookMock); +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const invitation = { + id: 'inv_1', + productId: 'lysnrai', + code: 'WELCOME-1', + description: 'Welcome offer', + createdBy: 'admin', + grantPlan: 'pro', + grantTrialDays: 14, + bonusTokens: 100, + maxUses: 5, + currentUses: 1, + redeemedBy: ['user_1'], + status: 'active', + expiresAt: null, + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +async function buildApp() { + const { invitationRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(invitationRoutes, { prefix: '/api' }); + return app; +} + +describe('invitationRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /invitations lists codes', async () => { + repoMock.list.mockResolvedValue([invitation]); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/invitations?limit=10&offset=0' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.invitations).toHaveLength(1); + expect(data.count).toBe(1); + }); + + it('GET /invitations/count returns total', async () => { + repoMock.count.mockResolvedValue(3); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/invitations/count' }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).count).toBe(3); + }); + + it('GET /invitations/:id returns 404 when not found', async () => { + repoMock.getById.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/invitations/missing' }); + + expect(res.statusCode).toBe(404); + }); + + it('POST /invitations creates code', async () => { + repoMock.create.mockResolvedValue(invitation); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/invitations', + payload: { + code: 'welcome-1', + createdBy: 'admin', + grantPlan: 'pro', + }, + }); + + expect(res.statusCode).toBe(201); + expect(repoMock.create).toHaveBeenCalled(); + }); + + it('PUT /invitations/:id updates code', async () => { + repoMock.update.mockResolvedValue({ ...invitation, status: 'disabled' }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/invitations/inv_1', + payload: { status: 'disabled' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).status).toBe('disabled'); + }); + + it('DELETE /invitations/:id returns 204', async () => { + repoMock.remove.mockResolvedValue(true); + const app = await buildApp(); + + const res = await app.inject({ method: 'DELETE', url: '/api/invitations/inv_1' }); + + expect(res.statusCode).toBe(204); + }); + + it('POST /invitations/bulk returns 207 with mixed success', async () => { + repoMock.create.mockResolvedValueOnce(invitation).mockRejectedValueOnce(new Error('duplicate')); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/invitations/bulk', + payload: [ + { code: 'first', createdBy: 'admin', grantPlan: 'pro' }, + { code: 'second', createdBy: 'admin', grantPlan: 'pro' }, + ], + }); + + expect(res.statusCode).toBe(207); + const data = JSON.parse(res.body); + expect(data.created).toBe(1); + expect(data.failed).toBe(1); + }); + + it('POST /invitations/redeem redeems and dispatches webhook', async () => { + repoMock.redeem.mockResolvedValue(invitation); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/invitations/redeem', + payload: { code: 'WELCOME-1', userId: 'user_2' }, + }); + + expect(res.statusCode).toBe(200); + expect(webhookMock.dispatchInvitationRedeemed).toHaveBeenCalled(); + }); + + it('POST /invitations/redeem returns 400 when invalid', async () => { + repoMock.redeem.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/invitations/redeem', + payload: { code: 'BAD', userId: 'user_2' }, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/services/platform-service/src/modules/items/routes.test.ts b/services/platform-service/src/modules/items/routes.test.ts new file mode 100644 index 00000000..8fc2d77f --- /dev/null +++ b/services/platform-service/src/modules/items/routes.test.ts @@ -0,0 +1,163 @@ +/** + * Route-level tests for tracker items module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +}; + +const authMock = { + extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('../../lib/auth.js', () => authMock); +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const baseItem = { + id: 'trk_1', + productId: 'lysnrai', + type: 'feature', + status: 'open', + priority: 'high', + title: 'Add better dictation UX', + description: 'Improve flow', + labels: ['ux'], + assignee: null, + reportedBy: 'user_1', + source: 'internal', + visibility: 'internal', + voteCount: 1, + commentCount: 0, + priorityOrder: 1, + targetRelease: null, + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +async function buildApp() { + const { itemRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(itemRoutes, { prefix: '/api' }); + return app; +} + +describe('itemRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /items/stats returns aggregates', async () => { + repoMock.list.mockResolvedValue({ items: [baseItem], total: 1 }); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/items/stats' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.total).toBe(1); + expect(data.byType.feature).toBe(1); + expect(data.requestedBy).toBe('user_1'); + }); + + it('GET /items returns 400 for invalid query', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/items?limit=0' }); + expect(res.statusCode).toBe(400); + }); + + it('GET /items returns paginated list', async () => { + repoMock.list.mockResolvedValue({ items: [baseItem], total: 1 }); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/items?limit=10&offset=0' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.items).toHaveLength(1); + expect(data.total).toBe(1); + }); + + it('GET /items/:id returns 404 when missing', async () => { + repoMock.getById.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/items/missing' }); + + expect(res.statusCode).toBe(404); + }); + + it('POST /items creates item', async () => { + repoMock.create.mockResolvedValue(baseItem); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/items', + payload: { + type: 'feature', + priority: 'high', + title: 'Add better dictation UX', + }, + }); + + expect(res.statusCode).toBe(201); + expect(repoMock.create).toHaveBeenCalled(); + }); + + it('PUT /items/:id updates item and priorityOrder', async () => { + repoMock.getById.mockResolvedValue(baseItem); + repoMock.update.mockResolvedValue({ ...baseItem, priority: 'critical', priorityOrder: 0 }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/items/trk_1', + payload: { priority: 'critical', title: 'Urgent dictation fix' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.priority).toBe('critical'); + }); + + it('PATCH /items/:id/status updates status', async () => { + repoMock.getById.mockResolvedValue(baseItem); + repoMock.update.mockResolvedValue({ ...baseItem, status: 'done' }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/items/trk_1/status', + payload: { status: 'done' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.status).toBe('done'); + }); + + it('DELETE /items/:id deletes existing item', async () => { + repoMock.getById.mockResolvedValue(baseItem); + repoMock.remove.mockResolvedValue(undefined); + const app = await buildApp(); + + const res = await app.inject({ method: 'DELETE', url: '/api/items/trk_1' }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); +});