assets, move memories to proper location
This commit is contained in:
408
packages/report/src/data-collector.test.ts
Normal file
408
packages/report/src/data-collector.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
collectExposureSummary,
|
||||
collectSpamStats,
|
||||
collectVoiceStats,
|
||||
collectHomeTitleStats,
|
||||
generateRecommendations,
|
||||
calculateProtectionScore,
|
||||
collectAllReportData,
|
||||
} from './data-collector';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockExposureFindMany = vi.fn();
|
||||
const mockAlertCount = vi.fn();
|
||||
const mockSpamFeedbackFindMany = vi.fn();
|
||||
const mockVoiceAnalysisFindMany = vi.fn();
|
||||
const mockVoiceEnrollmentCount = vi.fn();
|
||||
const mockWatchlistItemFindMany = vi.fn();
|
||||
const mockPrisma = {
|
||||
exposure: { findMany: mockExposureFindMany },
|
||||
alert: { count: mockAlertCount },
|
||||
spamFeedback: { findMany: mockSpamFeedbackFindMany },
|
||||
voiceAnalysis: { findMany: mockVoiceAnalysisFindMany },
|
||||
voiceEnrollment: { count: mockVoiceEnrollmentCount },
|
||||
watchlistItem: { findMany: mockWatchlistItemFindMany },
|
||||
};
|
||||
return {
|
||||
mockExposureFindMany,
|
||||
mockAlertCount,
|
||||
mockSpamFeedbackFindMany,
|
||||
mockVoiceAnalysisFindMany,
|
||||
mockVoiceEnrollmentCount,
|
||||
mockWatchlistItemFindMany,
|
||||
mockPrisma,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
mockExposureFindMany,
|
||||
mockAlertCount,
|
||||
mockSpamFeedbackFindMany,
|
||||
mockVoiceAnalysisFindMany,
|
||||
mockVoiceEnrollmentCount,
|
||||
mockWatchlistItemFindMany,
|
||||
mockPrisma,
|
||||
} = mocks;
|
||||
|
||||
vi.mock('@shieldai/db', () => ({
|
||||
prisma: mocks.mockPrisma,
|
||||
}));
|
||||
|
||||
describe('data-collector', () => {
|
||||
const periodStart = new Date('2025-01-01');
|
||||
const periodEnd = new Date('2025-01-31');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collectExposureSummary', () => {
|
||||
it('returns correct counts for mixed severity exposures', async () => {
|
||||
mockExposureFindMany.mockResolvedValue([
|
||||
{ severity: 'critical', source: 'breach1', isFirstTime: true },
|
||||
{ severity: 'warning', source: 'breach2', isFirstTime: true },
|
||||
{ severity: 'info', source: 'breach1', isFirstTime: false },
|
||||
]);
|
||||
mockAlertCount.mockResolvedValue(1);
|
||||
|
||||
const result = await collectExposureSummary('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalExposures).toBe(3);
|
||||
expect(result.newExposures).toBe(2);
|
||||
expect(result.criticalExposures).toBe(1);
|
||||
expect(result.warningExposures).toBe(1);
|
||||
expect(result.infoExposures).toBe(1);
|
||||
expect(result.resolvedExposures).toBe(1);
|
||||
expect(result.exposuresBySource).toEqual({ breach1: 2, breach2: 1 });
|
||||
});
|
||||
|
||||
it('returns zeros when no exposures', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
|
||||
const result = await collectExposureSummary('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalExposures).toBe(0);
|
||||
expect(result.newExposures).toBe(0);
|
||||
expect(result.criticalExposures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSpamStats', () => {
|
||||
it('calculates spam stats correctly', async () => {
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([
|
||||
{ isSpam: true, feedbackType: 'initial_detection', metadata: { channel: 'call' } },
|
||||
{ isSpam: true, feedbackType: 'initial_detection', metadata: { channel: 'sms' } },
|
||||
{ isSpam: true, feedbackType: 'user_rejection', metadata: { channel: 'call' } },
|
||||
{ isSpam: false, feedbackType: 'initial_detection', metadata: { channel: 'call' } },
|
||||
]);
|
||||
|
||||
const result = await collectSpamStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.callsBlocked).toBe(2);
|
||||
expect(result.textsBlocked).toBe(1);
|
||||
expect(result.falsePositives).toBe(1);
|
||||
expect(result.totalSpamEvents).toBe(3);
|
||||
});
|
||||
|
||||
it('returns zeros when no spam events', async () => {
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await collectSpamStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalSpamEvents).toBe(0);
|
||||
expect(result.callsBlocked).toBe(0);
|
||||
expect(result.textsBlocked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectVoiceStats', () => {
|
||||
it('calculates voice stats correctly', async () => {
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([
|
||||
{ isSynthetic: true, confidence: 0.9, enrollmentId: 'enr-1' },
|
||||
{ isSynthetic: true, confidence: 0.5, enrollmentId: 'enr-1' },
|
||||
{ isSynthetic: false, confidence: 0.8, enrollmentId: 'enr-2' },
|
||||
]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(2);
|
||||
|
||||
const result = await collectVoiceStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.analysesRun).toBe(3);
|
||||
expect(result.threatsDetected).toBe(1);
|
||||
expect(result.syntheticDetections).toBe(2);
|
||||
expect(result.enrollmentsActive).toBe(2);
|
||||
});
|
||||
|
||||
it('returns zeros when no analyses', async () => {
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
|
||||
const result = await collectVoiceStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.analysesRun).toBe(0);
|
||||
expect(result.threatsDetected).toBe(0);
|
||||
expect(result.enrollmentsActive).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectHomeTitleStats', () => {
|
||||
it('calculates home title stats', async () => {
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([
|
||||
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
|
||||
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
|
||||
]);
|
||||
mockAlertCount.mockResolvedValue(10);
|
||||
|
||||
const result = await collectHomeTitleStats('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.propertiesMonitored).toBe(2);
|
||||
expect(result.alertsTriggered).toBe(10);
|
||||
expect(result.changesDetected).toBe(Math.round(10 * 0.3));
|
||||
});
|
||||
|
||||
it('returns zeros when no watchlist items', async () => {
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
|
||||
const result = await collectHomeTitleStats('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.propertiesMonitored).toBe(0);
|
||||
expect(result.changesDetected).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateProtectionScore', () => {
|
||||
it('starts at 100 and deducts for issues', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 1,
|
||||
warningExposures: 2,
|
||||
infoExposures: 3,
|
||||
newExposures: 0,
|
||||
resolvedExposures: 0,
|
||||
exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 5,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
// 100 - 10 (1 critical) - 10 (2 warnings) - 6 (3 info) - 5 (spam) = 69
|
||||
expect(score).toBe(69);
|
||||
});
|
||||
|
||||
it('caps score between 0 and 100', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 15,
|
||||
warningExposures: 10,
|
||||
infoExposures: 10,
|
||||
newExposures: 0,
|
||||
resolvedExposures: 0,
|
||||
exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 30,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 5, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
// 100 - 150 - 50 - 20 - 20 - 40 - 5 = -85, capped to 0
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it('deducts 5 for no active enrollments', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
expect(score).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRecommendations', () => {
|
||||
it('suggests addressing critical exposures', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 3, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].priority).toBe('high');
|
||||
expect(recs[0].category).toBe('dark_web');
|
||||
});
|
||||
|
||||
it('suggests reviewing new exposures when > 5', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 10, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].title).toBe('Review New Exposures');
|
||||
});
|
||||
|
||||
it('suggests voice cloning threat when synthetic detected', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 10, threatsDetected: 2, enrollmentsActive: 1,
|
||||
syntheticDetections: 2, voiceMismatchEvents: 2,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].category).toBe('voice');
|
||||
expect(recs[0].priority).toBe('high');
|
||||
});
|
||||
|
||||
it('suggests enrolling voices when no active enrollments', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].priority).toBe('low');
|
||||
});
|
||||
|
||||
it('suggests improving protection score when < 50', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 45);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].category).toBe('general');
|
||||
expect(recs[0].priority).toBe('high');
|
||||
});
|
||||
|
||||
it('returns multiple recommendations for complex scenario', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 2, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 8, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 25,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 10, threatsDetected: 1, enrollmentsActive: 0,
|
||||
syntheticDetections: 1, voiceMismatchEvents: 1,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 40);
|
||||
|
||||
expect(recs.length).toBeGreaterThan(2);
|
||||
const categories = recs.map((r) => r.category);
|
||||
expect(categories).toContain('dark_web');
|
||||
expect(categories).toContain('spam');
|
||||
expect(categories).toContain('voice');
|
||||
expect(categories).toContain('general');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectAllReportData', () => {
|
||||
it('includes homeTitleStats for ANNUAL_PREMIUM', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await collectAllReportData(
|
||||
'user-1', 'sub-1', 'ANNUAL_PREMIUM', periodStart, periodEnd
|
||||
);
|
||||
|
||||
expect(result.homeTitleStats).toBeDefined();
|
||||
expect(result.exposureSummary).toBeDefined();
|
||||
expect(result.spamStats).toBeDefined();
|
||||
expect(result.voiceStats).toBeDefined();
|
||||
expect(result.recommendations).toBeDefined();
|
||||
expect(result.protectionScore).toBeDefined();
|
||||
});
|
||||
|
||||
it('excludes homeTitleStats for MONTHLY_PLUS', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
|
||||
const result = await collectAllReportData(
|
||||
'user-1', 'sub-1', 'MONTHLY_PLUS', periodStart, periodEnd
|
||||
);
|
||||
|
||||
expect(result.homeTitleStats).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/report/src/html-renderer.test.ts
Normal file
147
packages/report/src/html-renderer.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { htmlRenderer } from './html-renderer';
|
||||
|
||||
const mockData = {
|
||||
exposureSummary: {
|
||||
totalExposures: 10,
|
||||
newExposures: 3,
|
||||
resolvedExposures: 2,
|
||||
criticalExposures: 1,
|
||||
warningExposures: 4,
|
||||
infoExposures: 5,
|
||||
exposuresBySource: { breach1: 5, breach2: 5 },
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 15,
|
||||
textsBlocked: 20,
|
||||
callsFlagged: 5,
|
||||
textsFlagged: 8,
|
||||
falsePositives: 2,
|
||||
totalSpamEvents: 35,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 50,
|
||||
threatsDetected: 3,
|
||||
enrollmentsActive: 2,
|
||||
syntheticDetections: 3,
|
||||
voiceMismatchEvents: 3,
|
||||
},
|
||||
recommendations: [
|
||||
{
|
||||
category: 'dark_web',
|
||||
priority: 'high',
|
||||
title: 'Address Critical Exposures',
|
||||
description: '1 critical exposure detected.',
|
||||
},
|
||||
],
|
||||
protectionScore: 75,
|
||||
};
|
||||
|
||||
const renderContext = {
|
||||
reportTitle: 'Monthly Protection Report — January 2025',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
periodStart: '2025-01-01T00:00:00.000Z',
|
||||
periodEnd: '2025-01-31T23:59:59.000Z',
|
||||
generatedAt: '2025-02-01T00:00:00.000Z',
|
||||
userName: 'user-1',
|
||||
data: mockData,
|
||||
dashboardUrl: 'https://app.shieldai.com/reports/test-1',
|
||||
reportId: 'test-1',
|
||||
};
|
||||
|
||||
describe('HtmlRenderer', () => {
|
||||
it('renders valid HTML with report title', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('Monthly Protection Report — January 2025');
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('renders protection score', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('75');
|
||||
expect(html).toContain('/ 100');
|
||||
});
|
||||
|
||||
it('uses high score class for score >= 70', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('score-ring high');
|
||||
});
|
||||
|
||||
it('uses medium score class for score 40-69', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: { ...mockData, protectionScore: 50 },
|
||||
});
|
||||
expect(html).toContain('score-ring medium');
|
||||
});
|
||||
|
||||
it('uses low score class for score < 40', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: { ...mockData, protectionScore: 30 },
|
||||
});
|
||||
expect(html).toContain('score-ring low');
|
||||
});
|
||||
|
||||
it('renders exposure summary stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('1'); // critical
|
||||
expect(html).toContain('4'); // warnings
|
||||
expect(html).toContain('3'); // new findings
|
||||
expect(html).toContain('2'); // resolved
|
||||
});
|
||||
|
||||
it('renders spam protection stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('15'); // calls blocked
|
||||
expect(html).toContain('20'); // texts blocked
|
||||
expect(html).toContain('35'); // total events
|
||||
});
|
||||
|
||||
it('renders voice protection stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('50'); // analyses run
|
||||
expect(html).toContain('3'); // threats detected
|
||||
expect(html).toContain('2'); // enrollments active
|
||||
});
|
||||
|
||||
it('renders recommendations with priority styling', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('recommendation high');
|
||||
expect(html).toContain('Address Critical Exposures');
|
||||
});
|
||||
|
||||
it('renders home title stats when present', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: {
|
||||
...mockData,
|
||||
homeTitleStats: {
|
||||
propertiesMonitored: 2,
|
||||
changesDetected: 1,
|
||||
alertsTriggered: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(html).toContain('Home Title Monitoring');
|
||||
expect(html).toContain('Properties Monitored');
|
||||
});
|
||||
|
||||
it('omits home title section when stats are undefined', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).not.toContain('Home Title Monitoring');
|
||||
});
|
||||
|
||||
it('renders dashboard URL and report ID in footer', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('https://app.shieldai.com/reports/test-1');
|
||||
expect(html).toContain('test-1');
|
||||
});
|
||||
|
||||
it('renders exposure sources table', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('breach1');
|
||||
expect(html).toContain('breach2');
|
||||
});
|
||||
});
|
||||
152
packages/report/src/pdf-generator.test.ts
Normal file
152
packages/report/src/pdf-generator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pdfGenerator } from './pdf-generator';
|
||||
|
||||
const mockData = {
|
||||
exposureSummary: {
|
||||
totalExposures: 10,
|
||||
newExposures: 3,
|
||||
resolvedExposures: 2,
|
||||
criticalExposures: 1,
|
||||
warningExposures: 4,
|
||||
infoExposures: 5,
|
||||
exposuresBySource: {},
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 15,
|
||||
textsBlocked: 20,
|
||||
callsFlagged: 5,
|
||||
textsFlagged: 8,
|
||||
falsePositives: 2,
|
||||
totalSpamEvents: 35,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 50,
|
||||
threatsDetected: 3,
|
||||
enrollmentsActive: 2,
|
||||
syntheticDetections: 3,
|
||||
voiceMismatchEvents: 3,
|
||||
},
|
||||
recommendations: [],
|
||||
protectionScore: 75,
|
||||
};
|
||||
|
||||
const pdfContext = {
|
||||
reportTitle: 'Monthly Protection Report — January 2025',
|
||||
periodStart: '2025-01-01T00:00:00.000Z',
|
||||
periodEnd: '2025-01-31T23:59:59.000Z',
|
||||
generatedAt: '2025-02-01T00:00:00.000Z',
|
||||
data: mockData,
|
||||
reportId: 'test-1',
|
||||
};
|
||||
|
||||
describe('PdfGenerator', () => {
|
||||
it('generates a non-empty PDF buffer', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('PDF starts with PDF magic bytes', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const header = pdf.subarray(0, 5).toString();
|
||||
expect(header).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('PDF ends with %%EOF', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const footer = pdf.subarray(-6).toString();
|
||||
expect(footer).toContain('%%EOF');
|
||||
});
|
||||
|
||||
it('PDF contains xref table', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
expect(text).toContain('xref');
|
||||
expect(text).toContain('trailer');
|
||||
expect(text).toContain('startxref');
|
||||
});
|
||||
|
||||
it('PDF contains multiple pages', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
// PDFKit creates multiple pages for our report
|
||||
expect(text).toContain('/Count 3');
|
||||
});
|
||||
|
||||
it('PDF registers both font families', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
expect(text).toContain('Helvetica');
|
||||
expect(text).toContain('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('generates PDF with home title section (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const premiumPdf = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
homeTitleStats: {
|
||||
propertiesMonitored: 2,
|
||||
changesDetected: 1,
|
||||
alertsTriggered: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Premium report with extra section should be larger
|
||||
expect(premiumPdf.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates PDF with recommendations (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const withRecs = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
recommendations: [
|
||||
{
|
||||
category: 'dark_web',
|
||||
priority: 'high',
|
||||
title: 'Address Critical Exposures',
|
||||
description: '1 critical exposure detected.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// Report with recommendations should be larger
|
||||
expect(withRecs.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates PDF with score change (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const withChange = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
protectionScore: 80,
|
||||
previousProtectionScore: 70,
|
||||
},
|
||||
});
|
||||
// Report with score change text should be larger
|
||||
expect(withChange.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates valid PDF with all required sections', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
// Structural validation
|
||||
expect(text).toContain('%PDF-');
|
||||
expect(text).toContain('/Type /Catalog');
|
||||
expect(text).toContain('/Type /Pages');
|
||||
expect(text).toContain('/Type /Page');
|
||||
expect(text).toContain('/ProcSet [/PDF /Text');
|
||||
});
|
||||
|
||||
it('handles empty recommendations gracefully', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(100);
|
||||
const header = pdf.subarray(0, 5).toString();
|
||||
expect(header).toBe('%PDF-');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdfkit';
|
||||
import PDFKit from 'pdfkit';
|
||||
import { ReportDataPayload } from '@shieldai/types';
|
||||
|
||||
interface PdfContext {
|
||||
@@ -27,14 +27,14 @@ function getScoreColor(score: number): string {
|
||||
export class PdfGenerator {
|
||||
async generate(context: PdfContext): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
const doc = new PDFKit({
|
||||
size: 'A4',
|
||||
margins: { top: 40, bottom: 40, left: 40, right: 40 },
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PdfGenerator {
|
||||
.rect(0, 0, w, 120)
|
||||
.fill('#1e40af')
|
||||
.fillColor('white')
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(24)
|
||||
.text(context.reportTitle, 40, 30, { align: 'center' })
|
||||
.fontSize(12)
|
||||
@@ -63,7 +63,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor(scoreColor)
|
||||
.fontSize(48)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`${score}/100`, 40, y, { align: 'center' });
|
||||
y += 60;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor('#64748b')
|
||||
.fontSize(11)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text(changeText, 40, y, { align: 'center' });
|
||||
y += 20;
|
||||
}
|
||||
@@ -125,10 +125,10 @@ export class PdfGenerator {
|
||||
.rect(40, y, 4, 30)
|
||||
.fill(priorityColor)
|
||||
.fillColor('#1a202c')
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(12)
|
||||
.text(rec.title, 50, y + 2, { width: w - 100 })
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor('#475569')
|
||||
.text(rec.description, 50, y + 18, { width: w - 100 });
|
||||
@@ -142,7 +142,7 @@ export class PdfGenerator {
|
||||
.fill('#f5f7fa')
|
||||
.fillColor('#94a3b8')
|
||||
.fontSize(10)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text('ShieldAI — Your Digital Identity Protection', 40, h - 45, { align: 'center' })
|
||||
.text(`Report ID: ${context.reportId}`, 40, h - 30, { align: 'center' });
|
||||
|
||||
@@ -150,7 +150,7 @@ export class PdfGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
private drawSectionHeader(doc: PDFDocument, title: string, y: number): number {
|
||||
private drawSectionHeader(doc: PDFKit.PDFDocument, title: string, y: number): number {
|
||||
if (y > 680) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
@@ -159,7 +159,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor('#1e40af')
|
||||
.fontSize(16)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(title, 40, y)
|
||||
.rect(40, y + 18, 480, 2)
|
||||
.fill('#e2e8f0');
|
||||
@@ -168,7 +168,7 @@ export class PdfGenerator {
|
||||
}
|
||||
|
||||
private drawStatGrid(
|
||||
doc: PDFDocument,
|
||||
doc: PDFKit.PDFDocument,
|
||||
stats: Array<{ label: string; value: number; color: string }>,
|
||||
y: number
|
||||
): number {
|
||||
@@ -185,11 +185,11 @@ export class PdfGenerator {
|
||||
.fill('#f8fafc')
|
||||
.fillColor(stat.color)
|
||||
.fontSize(20)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(String(stat.value), x + 4, y + 8, { width: colWidth - 16, align: 'center' })
|
||||
.fillColor('#64748b')
|
||||
.fontSize(9)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text(stat.label, x + 4, y + 35, { width: colWidth - 16, align: 'center' });
|
||||
}
|
||||
y += 70;
|
||||
|
||||
319
packages/report/src/report.service.test.ts
Normal file
319
packages/report/src/report.service.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { prisma } from '@shieldai/db';
|
||||
import {
|
||||
ReportType,
|
||||
@@ -10,6 +12,8 @@ import { collectAllReportData } from './data-collector';
|
||||
import { htmlRenderer } from './html-renderer';
|
||||
import { pdfGenerator } from './pdf-generator';
|
||||
|
||||
const PDF_STORAGE_DIR = process.env.PDF_STORAGE_DIR || path.join(process.cwd(), 'storage', 'reports', 'pdfs');
|
||||
|
||||
export class ReportService {
|
||||
async generateReport(input: GenerateReportInput): Promise<SecurityReportOutput> {
|
||||
const { userId, subscriptionId, reportType, periodStart, periodEnd } = input;
|
||||
@@ -265,7 +269,15 @@ export class ReportService {
|
||||
}
|
||||
|
||||
private storePdf(pdfBuffer: Buffer, reportId: string): string {
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
const pdfPath = path.join(PDF_STORAGE_DIR, `${reportId}.pdf`);
|
||||
|
||||
const dir = path.dirname(pdfPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
|
||||
const dashboardUrl = process.env.DASHBOARD_URL || 'https://app.shieldai.com';
|
||||
return `${dashboardUrl}/api/v1/reports/${reportId}/pdf`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user