test(platform-service): add route coverage for items invitations audit

This commit is contained in:
saravanakumardb1 2026-02-16 15:00:13 -08:00
parent 7179f1e7c4
commit 9fbaf60ba8
3 changed files with 443 additions and 0 deletions

View File

@ -0,0 +1,101 @@
/**
* Route-level tests for audit module Fastify inject.
*/
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const repoMock = {
create: vi.fn(),
query: vi.fn(),
getStats: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
}));
async function buildApp() {
const { auditRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(auditRoutes, { prefix: '/api' });
return app;
}
describe('auditRoutes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('POST /audit accepts valid payload (fire-and-forget)', async () => {
repoMock.create.mockResolvedValue(undefined);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/audit',
payload: {
userId: 'user_1',
action: 'item.created',
category: 'tracker',
details: { itemId: 'trk_1' },
},
});
expect(res.statusCode).toBe(202);
expect(JSON.parse(res.body).accepted).toBe(true);
expect(repoMock.create).toHaveBeenCalled();
});
it('POST /audit returns 400 on invalid payload', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/audit',
payload: { action: '' },
});
expect(res.statusCode).toBe(400);
});
it('GET /audit returns records', async () => {
repoMock.query.mockResolvedValue([{ id: 'aud_1', action: 'item.created' }]);
const app = await buildApp();
const res = await app.inject({
method: 'GET',
url: '/api/audit?userId=user_1&days=7&limit=10&offset=0',
});
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.records).toHaveLength(1);
expect(data.count).toBe(1);
});
it('GET /audit returns 400 for invalid query', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/audit?days=0' });
expect(res.statusCode).toBe(400);
});
it('GET /audit/stats returns stats for given days', async () => {
repoMock.getStats.mockResolvedValue({ item_created: 3, login: 5 });
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/audit/stats?days=14' });
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.days).toBe(14);
expect(data.stats.item_created).toBe(3);
});
});

View File

@ -0,0 +1,179 @@
/**
* Route-level tests for invitations module Fastify inject.
*/
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const repoMock = {
list: vi.fn(),
count: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
redeem: vi.fn(),
};
const webhookMock = {
dispatchInvitationRedeemed: vi.fn(),
};
vi.mock('./repository.js', () => repoMock);
vi.mock('../../lib/webhooks.js', () => webhookMock);
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
}));
const invitation = {
id: 'inv_1',
productId: 'lysnrai',
code: 'WELCOME-1',
description: 'Welcome offer',
createdBy: 'admin',
grantPlan: 'pro',
grantTrialDays: 14,
bonusTokens: 100,
maxUses: 5,
currentUses: 1,
redeemedBy: ['user_1'],
status: 'active',
expiresAt: null,
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
async function buildApp() {
const { invitationRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(invitationRoutes, { prefix: '/api' });
return app;
}
describe('invitationRoutes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('GET /invitations lists codes', async () => {
repoMock.list.mockResolvedValue([invitation]);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/invitations?limit=10&offset=0' });
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.invitations).toHaveLength(1);
expect(data.count).toBe(1);
});
it('GET /invitations/count returns total', async () => {
repoMock.count.mockResolvedValue(3);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/invitations/count' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).count).toBe(3);
});
it('GET /invitations/:id returns 404 when not found', async () => {
repoMock.getById.mockResolvedValue(null);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/invitations/missing' });
expect(res.statusCode).toBe(404);
});
it('POST /invitations creates code', async () => {
repoMock.create.mockResolvedValue(invitation);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/invitations',
payload: {
code: 'welcome-1',
createdBy: 'admin',
grantPlan: 'pro',
},
});
expect(res.statusCode).toBe(201);
expect(repoMock.create).toHaveBeenCalled();
});
it('PUT /invitations/:id updates code', async () => {
repoMock.update.mockResolvedValue({ ...invitation, status: 'disabled' });
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/invitations/inv_1',
payload: { status: 'disabled' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).status).toBe('disabled');
});
it('DELETE /invitations/:id returns 204', async () => {
repoMock.remove.mockResolvedValue(true);
const app = await buildApp();
const res = await app.inject({ method: 'DELETE', url: '/api/invitations/inv_1' });
expect(res.statusCode).toBe(204);
});
it('POST /invitations/bulk returns 207 with mixed success', async () => {
repoMock.create.mockResolvedValueOnce(invitation).mockRejectedValueOnce(new Error('duplicate'));
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/invitations/bulk',
payload: [
{ code: 'first', createdBy: 'admin', grantPlan: 'pro' },
{ code: 'second', createdBy: 'admin', grantPlan: 'pro' },
],
});
expect(res.statusCode).toBe(207);
const data = JSON.parse(res.body);
expect(data.created).toBe(1);
expect(data.failed).toBe(1);
});
it('POST /invitations/redeem redeems and dispatches webhook', async () => {
repoMock.redeem.mockResolvedValue(invitation);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/invitations/redeem',
payload: { code: 'WELCOME-1', userId: 'user_2' },
});
expect(res.statusCode).toBe(200);
expect(webhookMock.dispatchInvitationRedeemed).toHaveBeenCalled();
});
it('POST /invitations/redeem returns 400 when invalid', async () => {
repoMock.redeem.mockResolvedValue(null);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/invitations/redeem',
payload: { code: 'BAD', userId: 'user_2' },
});
expect(res.statusCode).toBe(400);
});
});

View File

@ -0,0 +1,163 @@
/**
* Route-level tests for tracker items module Fastify inject.
*/
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const repoMock = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const authMock = {
extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })),
};
vi.mock('./repository.js', () => repoMock);
vi.mock('../../lib/auth.js', () => authMock);
vi.mock('../../lib/request-context.js', () => ({
getRequestProductId: () => 'lysnrai',
}));
const baseItem = {
id: 'trk_1',
productId: 'lysnrai',
type: 'feature',
status: 'open',
priority: 'high',
title: 'Add better dictation UX',
description: 'Improve flow',
labels: ['ux'],
assignee: null,
reportedBy: 'user_1',
source: 'internal',
visibility: 'internal',
voteCount: 1,
commentCount: 0,
priorityOrder: 1,
targetRelease: null,
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
async function buildApp() {
const { itemRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
await app.register(itemRoutes, { prefix: '/api' });
return app;
}
describe('itemRoutes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('GET /items/stats returns aggregates', async () => {
repoMock.list.mockResolvedValue({ items: [baseItem], total: 1 });
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/items/stats' });
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.total).toBe(1);
expect(data.byType.feature).toBe(1);
expect(data.requestedBy).toBe('user_1');
});
it('GET /items returns 400 for invalid query', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/items?limit=0' });
expect(res.statusCode).toBe(400);
});
it('GET /items returns paginated list', async () => {
repoMock.list.mockResolvedValue({ items: [baseItem], total: 1 });
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/items?limit=10&offset=0' });
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.items).toHaveLength(1);
expect(data.total).toBe(1);
});
it('GET /items/:id returns 404 when missing', async () => {
repoMock.getById.mockResolvedValue(null);
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/items/missing' });
expect(res.statusCode).toBe(404);
});
it('POST /items creates item', async () => {
repoMock.create.mockResolvedValue(baseItem);
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/items',
payload: {
type: 'feature',
priority: 'high',
title: 'Add better dictation UX',
},
});
expect(res.statusCode).toBe(201);
expect(repoMock.create).toHaveBeenCalled();
});
it('PUT /items/:id updates item and priorityOrder', async () => {
repoMock.getById.mockResolvedValue(baseItem);
repoMock.update.mockResolvedValue({ ...baseItem, priority: 'critical', priorityOrder: 0 });
const app = await buildApp();
const res = await app.inject({
method: 'PUT',
url: '/api/items/trk_1',
payload: { priority: 'critical', title: 'Urgent dictation fix' },
});
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.priority).toBe('critical');
});
it('PATCH /items/:id/status updates status', async () => {
repoMock.getById.mockResolvedValue(baseItem);
repoMock.update.mockResolvedValue({ ...baseItem, status: 'done' });
const app = await buildApp();
const res = await app.inject({
method: 'PATCH',
url: '/api/items/trk_1/status',
payload: { status: 'done' },
});
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.status).toBe('done');
});
it('DELETE /items/:id deletes existing item', async () => {
repoMock.getById.mockResolvedValue(baseItem);
repoMock.remove.mockResolvedValue(undefined);
const app = await buildApp();
const res = await app.inject({ method: 'DELETE', url: '/api/items/trk_1' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
});