diff --git a/services/platform-service/src/modules/public/routes.test.ts b/services/platform-service/src/modules/public/routes.test.ts new file mode 100644 index 00000000..737e208c --- /dev/null +++ b/services/platform-service/src/modules/public/routes.test.ts @@ -0,0 +1,189 @@ +/** + * Route-level tests for public module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const itemRepoMock = { + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + updateVoteCount: vi.fn(), +}; + +const voteRepoMock = { + create: vi.fn(), + getByItemAndUser: vi.fn(), + remove: vi.fn(), + countByItem: vi.fn(), +}; + +vi.mock('../items/repository.js', () => itemRepoMock); +vi.mock('../votes/repository.js', () => voteRepoMock); +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const baseItem = { + id: 'item_1', + productId: 'lysnrai', + type: 'feature', + status: 'open', + priority: 'medium', + title: 'Improve transcription speed', + description: 'Improve performance for long audio', + labels: [], + assignee: null, + reportedBy: 'user@example.com', + source: 'user_submitted', + visibility: 'public', + voteCount: 2, + commentCount: 0, + priorityOrder: 2, + targetRelease: null, + createdAt: '2026-02-16T00:00:00Z', + updatedAt: '2026-02-16T00:00:00Z', +}; + +describe('publicRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /public/roadmap returns public items', async () => { + itemRepoMock.list.mockResolvedValue({ items: [baseItem], total: 1 }); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/public/roadmap' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.items).toHaveLength(1); + expect(itemRepoMock.list).toHaveBeenCalled(); + }); + + it('GET /public/roadmap returns 400 for invalid query', async () => { + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/public/roadmap?status=invalid', + }); + + expect(res.statusCode).toBe(400); + }); + + it('GET /public/roadmap/stats aggregates status/type/votes', async () => { + itemRepoMock.list.mockResolvedValue({ + items: [ + { ...baseItem, status: 'open', type: 'feature', voteCount: 2 }, + { ...baseItem, id: 'item_2', status: 'in_progress', type: 'bug', voteCount: 3 }, + ], + total: 2, + }); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/public/roadmap/stats' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.total).toBe(2); + expect(data.totalVotes).toBe(5); + expect(data.byStatus.open).toBe(1); + expect(data.byType.feature).toBe(1); + }); + + it('GET /public/items/:id returns 404 for missing item', async () => { + itemRepoMock.getById.mockResolvedValue(null); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/public/items/missing' }); + + expect(res.statusCode).toBe(404); + }); + + it('POST /public/submit creates tracker item and auto-vote', async () => { + itemRepoMock.create.mockResolvedValue(baseItem); + voteRepoMock.create.mockResolvedValue({ id: 'vote_1' }); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/public/submit', + payload: { + type: 'feature', + priority: 'medium', + title: 'Add hotkey customization', + description: 'Need remap support', + email: 'submitter@example.com', + name: 'Submitter', + }, + }); + + expect(res.statusCode).toBe(201); + const data = JSON.parse(res.body); + expect(data).toHaveProperty('id'); + expect(voteRepoMock.create).toHaveBeenCalled(); + }); + + it('POST /public/items/:id/vote toggles off when vote exists', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + voteRepoMock.getByItemAndUser.mockResolvedValue({ id: 'vote_1' }); + voteRepoMock.countByItem.mockResolvedValue(1); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/public/items/item_1/vote', + payload: { email: 'user@example.com' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.voted).toBe(false); + expect(voteRepoMock.remove).toHaveBeenCalledWith('vote_1'); + }); + + it('POST /public/items/:id/vote toggles on when vote does not exist', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + voteRepoMock.getByItemAndUser.mockResolvedValue(null); + voteRepoMock.countByItem.mockResolvedValue(3); + + const { publicRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(publicRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/public/items/item_1/vote', + payload: { email: 'user@example.com' }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.voted).toBe(true); + expect(voteRepoMock.create).toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/settings/routes.test.ts b/services/platform-service/src/modules/settings/routes.test.ts new file mode 100644 index 00000000..ffbb0a04 --- /dev/null +++ b/services/platform-service/src/modules/settings/routes.test.ts @@ -0,0 +1,194 @@ +/** + * Route-level tests for settings module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + getSettingsId: vi.fn((productId: string, userId: string) => `settings_${productId}_${userId}`), + getByUserId: vi.fn(), + upsert: vi.fn(), +}; + +const flagRepoMock = { + getByKey: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('../flags/repository.js', () => flagRepoMock); +vi.mock('../../lib/request-context.js', () => ({ + getRequestProductId: () => 'lysnrai', +})); + +const now = '2026-02-16T00:00:00.000Z'; +const existingSettings = { + id: 'settings_lysnrai_user_1', + productId: 'lysnrai', + userId: 'user_1', + settings: { theme: 'dark', locale: 'en-US' }, + deviceOverrides: { iphone_1: { theme: 'light' } }, + createdAt: now, + updatedAt: now, +}; + +function buildApp(withAuth = true) { + return (async () => { + const { settingsRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (withAuth) { + app.addHook('onRequest', async req => { + req.jwtPayload = { sub: 'user_1', productId: 'lysnrai' }; + }); + } + await app.register(settingsRoutes, { prefix: '/api' }); + return app; + })(); +} + +describe('settingsRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /settings/kill-switch returns enabled when product is missing', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/settings/kill-switch' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.enabled).toBe(true); + expect(data.disabled).toBe(false); + }); + + it('GET /settings/kill-switch returns disabled when flag is enabled', async () => { + flagRepoMock.getByKey.mockResolvedValue({ enabled: true, description: 'Maintenance' }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/settings/kill-switch?productId=lysnrai', + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.enabled).toBe(false); + expect(data.disabled).toBe(true); + expect(data.message).toBe('Maintenance'); + }); + + it('GET /settings returns existing settings', async () => { + repoMock.getByUserId.mockResolvedValue(existingSettings); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/settings' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.settings.theme).toBe('dark'); + }); + + it('GET /settings returns default doc when none exists', async () => { + repoMock.getByUserId.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/settings' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.userId).toBe('user_1'); + expect(data.settings).toEqual({}); + }); + + it('GET /settings returns 401 without auth', async () => { + const app = await buildApp(false); + const res = await app.inject({ method: 'GET', url: '/api/settings' }); + expect(res.statusCode).toBe(401); + }); + + it('PUT /settings merges global settings', async () => { + repoMock.getByUserId.mockResolvedValue(existingSettings); + repoMock.upsert.mockResolvedValue({ + ...existingSettings, + settings: { ...existingSettings.settings, locale: 'fr-FR' }, + }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { settings: { locale: 'fr-FR' } }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.settings.locale).toBe('fr-FR'); + }); + + it('GET /settings/device/:deviceId resolves merged settings', async () => { + repoMock.getByUserId.mockResolvedValue(existingSettings); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/settings/device/iphone_1', + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.deviceId).toBe('iphone_1'); + expect(data.hasOverrides).toBe(true); + expect(data.settings.theme).toBe('light'); + }); + + it('PUT /settings/device/:deviceId sets overrides', async () => { + repoMock.getByUserId.mockResolvedValue(existingSettings); + repoMock.upsert.mockResolvedValue(existingSettings); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/settings/device/iphone_2', + payload: { overrides: { locale: 'es-ES' } }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.deviceId).toBe('iphone_2'); + expect(data.overrides.locale).toBe('es-ES'); + }); + + it('DELETE /settings/device/:deviceId returns success when no existing doc', async () => { + repoMock.getByUserId.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/settings/device/iphone_2', + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.success).toBe(true); + }); + + it('DELETE /settings/device/:deviceId clears overrides when doc exists', async () => { + repoMock.getByUserId.mockResolvedValue(existingSettings); + repoMock.upsert.mockResolvedValue({ ...existingSettings, deviceOverrides: {} }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/settings/device/iphone_1', + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.success).toBe(true); + expect(data.deviceId).toBe('iphone_1'); + }); +}); diff --git a/services/platform-service/src/modules/votes/routes.test.ts b/services/platform-service/src/modules/votes/routes.test.ts new file mode 100644 index 00000000..88f657ca --- /dev/null +++ b/services/platform-service/src/modules/votes/routes.test.ts @@ -0,0 +1,119 @@ +/** + * Route-level tests for votes module — Fastify inject. + */ + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const voteRepoMock = { + getByItemAndUser: vi.fn(), + create: vi.fn(), + remove: vi.fn(), + countByItem: vi.fn(), + listByItem: vi.fn(), +}; + +const itemRepoMock = { + getById: vi.fn(), + updateVoteCount: vi.fn(), +}; + +vi.mock('./repository.js', () => voteRepoMock); +vi.mock('../items/repository.js', () => itemRepoMock); +vi.mock('../../lib/auth.js', () => ({ + extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'user' })), +})); + +const baseItem = { + id: 'item_1', + productId: 'lysnrai', + title: 'Improve transcription speed', + visibility: 'public', +}; + +describe('voteRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /items/:itemId/vote adds vote when none exists', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + voteRepoMock.getByItemAndUser.mockResolvedValue(null); + voteRepoMock.countByItem.mockResolvedValue(3); + + const { voteRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(voteRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'POST', url: '/api/items/item_1/vote' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.voted).toBe(true); + expect(data.voteCount).toBe(3); + expect(voteRepoMock.create).toHaveBeenCalled(); + expect(itemRepoMock.updateVoteCount).toHaveBeenCalledWith('item_1', 3); + }); + + it('POST /items/:itemId/vote removes vote when existing', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + voteRepoMock.getByItemAndUser.mockResolvedValue({ id: 'vote_1' }); + voteRepoMock.countByItem.mockResolvedValue(2); + + const { voteRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(voteRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'POST', url: '/api/items/item_1/vote' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.voted).toBe(false); + expect(data.voteCount).toBe(2); + expect(voteRepoMock.remove).toHaveBeenCalledWith('vote_1'); + }); + + it('POST /items/:itemId/vote returns 404 when item missing', async () => { + itemRepoMock.getById.mockResolvedValue(null); + + const { voteRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(voteRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'POST', url: '/api/items/missing/vote' }); + + expect(res.statusCode).toBe(404); + }); + + it('GET /items/:itemId/votes lists voters', async () => { + itemRepoMock.getById.mockResolvedValue(baseItem); + voteRepoMock.listByItem.mockResolvedValue([{ id: 'vote_1' }, { id: 'vote_2' }]); + + const { voteRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(voteRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/items/item_1/votes' }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.votes).toHaveLength(2); + expect(data.count).toBe(2); + }); + + it('GET /items/:itemId/votes returns 404 when item missing', async () => { + itemRepoMock.getById.mockResolvedValue(null); + + const { voteRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + await app.register(voteRoutes, { prefix: '/api' }); + + const res = await app.inject({ method: 'GET', url: '/api/items/missing/votes' }); + + expect(res.statusCode).toBe(404); + }); +});