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