test(platform-service): add route tests for settings, votes, and public modules
This commit is contained in:
parent
c7934af227
commit
8576fe2e91
189
services/platform-service/src/modules/public/routes.test.ts
Normal file
189
services/platform-service/src/modules/public/routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
services/platform-service/src/modules/settings/routes.test.ts
Normal file
194
services/platform-service/src/modules/settings/routes.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
119
services/platform-service/src/modules/votes/routes.test.ts
Normal file
119
services/platform-service/src/modules/votes/routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user