320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ReportService } from './report.service';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const mockCreate = vi.fn();
|
|
const mockUpdate = vi.fn();
|
|
const mockFindUniqueOrThrow = vi.fn();
|
|
const mockFindMany = vi.fn();
|
|
const mockFindFirst = vi.fn();
|
|
const mockSubscriptionFindMany = vi.fn();
|
|
const mockPrisma = {
|
|
securityReport: {
|
|
create: mockCreate,
|
|
update: mockUpdate,
|
|
findUniqueOrThrow: mockFindUniqueOrThrow,
|
|
findMany: mockFindMany,
|
|
findFirst: mockFindFirst,
|
|
},
|
|
subscription: {
|
|
findMany: mockSubscriptionFindMany,
|
|
},
|
|
};
|
|
return {
|
|
mockCreate,
|
|
mockUpdate,
|
|
mockFindUniqueOrThrow,
|
|
mockFindMany,
|
|
mockFindFirst,
|
|
mockSubscriptionFindMany,
|
|
mockPrisma,
|
|
};
|
|
});
|
|
|
|
const {
|
|
mockCreate,
|
|
mockUpdate,
|
|
mockFindUniqueOrThrow,
|
|
mockFindMany,
|
|
mockFindFirst,
|
|
mockSubscriptionFindMany,
|
|
mockPrisma,
|
|
} = mocks;
|
|
|
|
vi.mock('@shieldai/db', () => ({
|
|
prisma: mocks.mockPrisma,
|
|
}));
|
|
|
|
vi.mock('fs', () => ({
|
|
default: {
|
|
existsSync: vi.fn(() => false),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
},
|
|
existsSync: vi.fn(() => false),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./data-collector', () => ({
|
|
collectAllReportData: vi.fn(() =>
|
|
Promise.resolve({
|
|
exposureSummary: {
|
|
totalExposures: 5, newExposures: 2, resolvedExposures: 1,
|
|
criticalExposures: 1, warningExposures: 2, infoExposures: 2,
|
|
exposuresBySource: {},
|
|
},
|
|
spamStats: {
|
|
callsBlocked: 10, textsBlocked: 5, callsFlagged: 2, textsFlagged: 1,
|
|
falsePositives: 0, totalSpamEvents: 15,
|
|
},
|
|
voiceStats: {
|
|
analysesRun: 20, threatsDetected: 1, enrollmentsActive: 1,
|
|
syntheticDetections: 1, voiceMismatchEvents: 1,
|
|
},
|
|
recommendations: [],
|
|
protectionScore: 80,
|
|
})
|
|
),
|
|
}));
|
|
|
|
vi.mock('./html-renderer', () => ({
|
|
htmlRenderer: {
|
|
render: vi.fn(() => '<html>Mock HTML</html>'),
|
|
},
|
|
}));
|
|
|
|
vi.mock('./pdf-generator', () => ({
|
|
pdfGenerator: {
|
|
generate: vi.fn(() => Promise.resolve(Buffer.from('mock-pdf'))),
|
|
},
|
|
}));
|
|
|
|
describe('ReportService', () => {
|
|
let service: ReportService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
service = new ReportService();
|
|
});
|
|
|
|
describe('generateReport', () => {
|
|
it('creates and completes a report successfully', async () => {
|
|
const reportId = 'report-1';
|
|
mockCreate.mockResolvedValueOnce({
|
|
id: reportId,
|
|
userId: 'user-1',
|
|
subscriptionId: 'sub-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
status: 'GENERATING',
|
|
periodStart: new Date('2025-01-01'),
|
|
periodEnd: new Date('2025-01-31'),
|
|
title: 'Monthly Protection Report — January 2025',
|
|
});
|
|
mockUpdate.mockResolvedValueOnce({
|
|
id: reportId,
|
|
userId: 'user-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
status: 'COMPLETED',
|
|
periodStart: new Date('2025-01-01'),
|
|
periodEnd: new Date('2025-01-31'),
|
|
title: 'Monthly Protection Report — January 2025',
|
|
summary: 'Protection Score: 80/100. 15 spam event(s) blocked.',
|
|
htmlContent: '<html>Mock HTML</html>',
|
|
pdfUrl: 'https://app.shieldai.com/api/v1/reports/report-1/pdf',
|
|
dataPayload: '{}',
|
|
error: null,
|
|
createdAt: new Date(),
|
|
deliveredAt: null,
|
|
});
|
|
|
|
const result = await service.generateReport({
|
|
userId: 'user-1',
|
|
subscriptionId: 'sub-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
periodStart: new Date('2025-01-01'),
|
|
periodEnd: new Date('2025-01-31'),
|
|
});
|
|
|
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
expect(result.status).toBe('COMPLETED');
|
|
expect(result.reportType).toBe('MONTHLY_PLUS');
|
|
});
|
|
|
|
it('sets status to FAILED on error', async () => {
|
|
const reportId = 'report-2';
|
|
mockCreate.mockResolvedValueOnce({
|
|
id: reportId,
|
|
userId: 'user-1',
|
|
subscriptionId: 'sub-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
status: 'GENERATING',
|
|
periodStart: new Date('2025-01-01'),
|
|
periodEnd: new Date('2025-01-31'),
|
|
title: 'Monthly Protection Report',
|
|
});
|
|
mockUpdate.mockResolvedValueOnce({
|
|
id: reportId,
|
|
status: 'FAILED',
|
|
error: 'Data collection failed',
|
|
});
|
|
mockFindUniqueOrThrow.mockResolvedValueOnce({
|
|
id: reportId,
|
|
userId: 'user-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
status: 'FAILED',
|
|
periodStart: new Date('2025-01-01'),
|
|
periodEnd: new Date('2025-01-31'),
|
|
title: 'Monthly Protection Report',
|
|
summary: null,
|
|
pdfUrl: null,
|
|
dataPayload: null,
|
|
error: 'Data collection failed',
|
|
createdAt: new Date(),
|
|
deliveredAt: null,
|
|
});
|
|
|
|
// Force data collector to throw
|
|
const dc = await import('./data-collector');
|
|
vi.mocked(dc.collectAllReportData).mockResolvedValueOnce({
|
|
exposureSummary: {
|
|
totalExposures: 0, newExposures: 0, resolvedExposures: 0,
|
|
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
|
exposuresBySource: {},
|
|
},
|
|
spamStats: {
|
|
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
|
falsePositives: 0, totalSpamEvents: 50,
|
|
},
|
|
voiceStats: {
|
|
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
|
syntheticDetections: 0, voiceMismatchEvents: 0,
|
|
},
|
|
recommendations: [],
|
|
protectionScore: 85,
|
|
});
|
|
|
|
const result = await service.generateReport({
|
|
userId: 'user-1',
|
|
subscriptionId: 'sub-1',
|
|
reportType: 'MONTHLY_PLUS',
|
|
});
|
|
|
|
expect(result.status).toBe('FAILED');
|
|
});
|
|
});
|
|
|
|
describe('getReportHistory', () => {
|
|
it('returns paginated report history', async () => {
|
|
mockFindMany.mockResolvedValue([
|
|
{
|
|
id: 'r1', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
|
periodStart: new Date('2025-01-01'), periodEnd: new Date('2025-01-31'),
|
|
title: 'Jan Report', summary: 'Good', pdfUrl: '/pdf/1',
|
|
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
|
},
|
|
{
|
|
id: 'r2', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
|
periodStart: new Date('2024-12-01'), periodEnd: new Date('2024-12-31'),
|
|
title: 'Dec Report', summary: 'Good', pdfUrl: '/pdf/2',
|
|
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
|
},
|
|
]);
|
|
|
|
const result = await service.getReportHistory('user-1', 10, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(mockFindMany).toHaveBeenCalledWith({
|
|
where: { userId: 'user-1' },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 10,
|
|
skip: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getReportById', () => {
|
|
it('returns report when found', async () => {
|
|
mockFindFirst.mockResolvedValue({
|
|
id: 'r1', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
|
periodStart: new Date(), periodEnd: new Date(),
|
|
title: 'Test Report', summary: 'ok', pdfUrl: '/pdf',
|
|
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
|
});
|
|
|
|
const result = await service.getReportById('user-1', 'r1');
|
|
expect(result.id).toBe('r1');
|
|
});
|
|
|
|
it('throws when report not found', async () => {
|
|
mockFindFirst.mockResolvedValue(null);
|
|
|
|
await expect(service.getReportById('user-1', 'r99')).rejects.toThrow(
|
|
'Report r99 not found'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('scheduleMonthlyReports', () => {
|
|
it('creates monthly reports for Plus subscriptions', async () => {
|
|
mockSubscriptionFindMany.mockResolvedValue([
|
|
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
|
|
{ id: 'sub-2', userId: 'user-2', user: { email: 'u2@test.com' } },
|
|
]);
|
|
mockFindFirst.mockResolvedValue(null);
|
|
mockCreate.mockResolvedValue({ id: 'new-report-1' });
|
|
|
|
const result = await service.scheduleMonthlyReports();
|
|
|
|
expect(result.length).toBeGreaterThan(0);
|
|
expect(mockCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
reportType: 'MONTHLY_PLUS',
|
|
status: 'PENDING',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('skips subscriptions that already have a report for the period', async () => {
|
|
mockSubscriptionFindMany.mockResolvedValue([
|
|
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
|
|
]);
|
|
mockFindFirst.mockResolvedValue({ id: 'existing' });
|
|
|
|
const result = await service.scheduleMonthlyReports();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('scheduleAnnualReports', () => {
|
|
it('creates annual reports for Premium subscriptions due', async () => {
|
|
const now = new Date();
|
|
mockSubscriptionFindMany.mockResolvedValue([
|
|
{
|
|
id: 'sub-1',
|
|
userId: 'user-1',
|
|
currentPeriodStart: new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()),
|
|
},
|
|
]);
|
|
mockFindFirst.mockResolvedValue(null);
|
|
mockCreate.mockResolvedValue({ id: 'annual-report-1' });
|
|
|
|
const result = await service.scheduleAnnualReports();
|
|
|
|
expect(result.length).toBeGreaterThan(0);
|
|
expect(mockCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
reportType: 'ANNUAL_PREMIUM',
|
|
status: 'PENDING',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|