test(platform-service): add route-level tests for plans + notifications — 379 tests, 35.4% stmts

This commit is contained in:
saravanakumardb1 2026-02-16 12:14:42 -08:00
parent 87f7d76915
commit 149d97dd9f
2 changed files with 345 additions and 0 deletions

View File

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

View File

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