test(platform-service): add route-level tests for plans + notifications — 379 tests, 35.4% stmts
This commit is contained in:
parent
87f7d76915
commit
149d97dd9f
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
163
services/platform-service/src/modules/plans/routes.test.ts
Normal file
163
services/platform-service/src/modules/plans/routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user