test(platform-service): add route tests for settings, votes, and public modules

This commit is contained in:
saravanakumardb1 2026-02-16 12:33:17 -08:00
parent c7934af227
commit 8576fe2e91
3 changed files with 502 additions and 0 deletions

View File

@ -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();
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});