assets, move memories to proper location

This commit is contained in:
2026-05-14 07:36:23 -04:00
parent 0bec3c574a
commit 1b917321cf
52 changed files with 3352 additions and 297 deletions

View 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();
});
});
});

View 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');
});
});

View 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-');
});
});

View File

@@ -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;

View 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',
}),
})
);
});
});
});

View File

@@ -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`;
}