test(api): add unit tests for analytics, metrics, notifications, payments, and search modules
New test coverage for infrastructure and presentation layers across multiple modules including Momo/ZaloPay payment services, Typesense search repository, listing indexer, and notification handlers. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { EmailService } from '../services/email.service';
|
||||
|
||||
vi.mock('nodemailer');
|
||||
|
||||
describe('EmailService', () => {
|
||||
let service: EmailService;
|
||||
let mockTransporter: { sendMail: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransporter = {
|
||||
sendMail: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(nodemailer.createTransport).mockReturnValue(mockTransporter as any);
|
||||
|
||||
mockLogger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
|
||||
|
||||
service = new EmailService(mockLogger as any);
|
||||
service.onModuleInit();
|
||||
});
|
||||
|
||||
it('send sends email and returns messageId', async () => {
|
||||
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-message-id' });
|
||||
|
||||
const result = await service.send({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
html: '<p>Test body</p>',
|
||||
});
|
||||
|
||||
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
html: '<p>Test body</p>',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ messageId: 'test-message-id' });
|
||||
});
|
||||
|
||||
it('send throws error when sendMail fails', async () => {
|
||||
const smtpError = new Error('SMTP connection refused');
|
||||
mockTransporter.sendMail.mockRejectedValue(smtpError);
|
||||
|
||||
await expect(
|
||||
service.send({ to: 'test@example.com', subject: 'Test', html: '<p>Test</p>' }),
|
||||
).rejects.toThrow('SMTP connection refused');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verify returns true when transporter verifies successfully', async () => {
|
||||
mockTransporter.verify.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verify();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTransporter.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verify returns false when transporter verify fails', async () => {
|
||||
mockTransporter.verify.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const result = await service.verify();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('onModuleInit creates transporter with env config', () => {
|
||||
process.env['SMTP_HOST'] = 'smtp.example.com';
|
||||
process.env['SMTP_PORT'] = '587';
|
||||
process.env['SMTP_USER'] = 'user@example.com';
|
||||
process.env['SMTP_PASS'] = 'secret';
|
||||
|
||||
const freshService = new EmailService(mockLogger as any);
|
||||
freshService.onModuleInit();
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
auth: { user: 'user@example.com', pass: 'secret' },
|
||||
}),
|
||||
);
|
||||
|
||||
delete process.env['SMTP_HOST'];
|
||||
delete process.env['SMTP_PORT'];
|
||||
delete process.env['SMTP_USER'];
|
||||
delete process.env['SMTP_PASS'];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FcmService } from '../services/fcm.service';
|
||||
|
||||
vi.mock('firebase-admin', () => ({
|
||||
apps: [],
|
||||
initializeApp: vi.fn(),
|
||||
credential: { cert: vi.fn() },
|
||||
messaging: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('FcmService', () => {
|
||||
let service: FcmService;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
delete process.env['FIREBASE_SERVICE_ACCOUNT'];
|
||||
service = new FcmService(mockLogger as any);
|
||||
});
|
||||
|
||||
it('isAvailable returns false when FIREBASE_SERVICE_ACCOUNT not set', () => {
|
||||
service.onModuleInit();
|
||||
|
||||
expect(service.isAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('send throws when not initialized', async () => {
|
||||
service.onModuleInit();
|
||||
|
||||
await expect(
|
||||
service.send({ token: 'device-token', title: 'Test', body: 'Test body' }),
|
||||
).rejects.toThrow('FCM not initialized — FIREBASE_SERVICE_ACCOUNT not configured');
|
||||
});
|
||||
|
||||
it('onModuleInit logs warning when env not set', () => {
|
||||
service.onModuleInit();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'FIREBASE_SERVICE_ACCOUNT not set — push notifications disabled',
|
||||
'FcmService',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { PrismaNotificationPreferenceRepository } from '../repositories/prisma-notification-preference.repository';
|
||||
|
||||
describe('PrismaNotificationPreferenceRepository', () => {
|
||||
let repository: PrismaNotificationPreferenceRepository;
|
||||
let mockPrisma: {
|
||||
notificationPreference: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockPreference = {
|
||||
id: 'pref-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
eventType: 'user.registered',
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
notificationPreference: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
};
|
||||
repository = new PrismaNotificationPreferenceRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('findByUserId returns preferences for user', async () => {
|
||||
const preferences = [mockPreference, { ...mockPreference, id: 'pref-2', channel: 'PUSH' }];
|
||||
mockPrisma.notificationPreference.findMany.mockResolvedValue(preferences);
|
||||
|
||||
const result = await repository.findByUserId('user-1');
|
||||
|
||||
expect(mockPrisma.notificationPreference.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: 'user-1' } }),
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('isEnabled returns true when no preference exists (default)', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('isEnabled returns false when preference is disabled', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('isEnabled returns true when preference is enabled', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('upsert creates or updates preference', async () => {
|
||||
mockPrisma.notificationPreference.upsert.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await repository.upsert('user-1', 'EMAIL', 'user.registered', true);
|
||||
|
||||
expect(mockPrisma.notificationPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_channel_eventType: { userId: 'user-1', channel: 'EMAIL', eventType: 'user.registered' } },
|
||||
create: { userId: 'user-1', channel: 'EMAIL', eventType: 'user.registered', enabled: true },
|
||||
update: { enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { PrismaNotificationRepository } from '../repositories/prisma-notification.repository';
|
||||
|
||||
describe('PrismaNotificationRepository', () => {
|
||||
let repository: PrismaNotificationRepository;
|
||||
let mockPrisma: {
|
||||
notificationLog: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockRecord = {
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
status: 'PENDING',
|
||||
errorDetail: null,
|
||||
sentAt: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
notificationLog: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
repository = new PrismaNotificationRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('create creates notification log with PENDING status and returns entity', async () => {
|
||||
mockPrisma.notificationLog.create.mockResolvedValue(mockRecord);
|
||||
|
||||
const result = await repository.create({
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
expect(mockPrisma.notificationLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
status: 'PENDING',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.id).toBe('notif-1');
|
||||
expect(result.status).toBe('PENDING');
|
||||
expect(result.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('updateStatus updates status to SENT with sentAt date', async () => {
|
||||
mockPrisma.notificationLog.update.mockResolvedValue({ ...mockRecord, status: 'SENT', sentAt: new Date() });
|
||||
|
||||
await repository.updateStatus('notif-1', 'SENT');
|
||||
|
||||
expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'SENT',
|
||||
sentAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updateStatus updates status to FAILED with errorDetail', async () => {
|
||||
mockPrisma.notificationLog.update.mockResolvedValue({
|
||||
...mockRecord,
|
||||
status: 'FAILED',
|
||||
errorDetail: 'SMTP connection refused',
|
||||
});
|
||||
|
||||
await repository.updateStatus('notif-1', 'FAILED', 'SMTP connection refused');
|
||||
|
||||
expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'FAILED',
|
||||
errorDetail: 'SMTP connection refused',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('findByUserId returns entities ordered by createdAt desc', async () => {
|
||||
const records = [
|
||||
{ ...mockRecord, id: 'notif-2', createdAt: new Date('2024-01-02') },
|
||||
{ ...mockRecord, id: 'notif-1', createdAt: new Date('2024-01-01') },
|
||||
];
|
||||
mockPrisma.notificationLog.findMany.mockResolvedValue(records);
|
||||
|
||||
const result = await repository.findByUserId('user-1');
|
||||
|
||||
expect(mockPrisma.notificationLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.id).toBe('notif-2');
|
||||
expect(result[1]!.id).toBe('notif-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TemplateService } from '../services/template.service';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let service: TemplateService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TemplateService();
|
||||
});
|
||||
|
||||
it('render returns rendered subject and body for user.registered template', () => {
|
||||
const result = service.render('user.registered', { phone: '0901234567', role: 'BUYER' });
|
||||
|
||||
expect(result.subject).toBe('Chào mừng bạn đến với GoodGo!');
|
||||
expect(result.body).toContain('0901234567');
|
||||
expect(result.body).toContain('BUYER');
|
||||
});
|
||||
|
||||
it('render returns rendered subject and body for quota.exceeded template', () => {
|
||||
const result = service.render('quota.exceeded', { metric: 'listings', used: 10, limit: 10 });
|
||||
|
||||
expect(result.subject).toBe('Bạn đã đạt giới hạn sử dụng');
|
||||
expect(result.body).toContain('listings');
|
||||
expect(result.body).toContain('10');
|
||||
});
|
||||
|
||||
it('render throws error for unknown template key', () => {
|
||||
expect(() => service.render('does.not.exist', {})).toThrow(
|
||||
'Notification template "does.not.exist" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('hasTemplate returns true for existing template', () => {
|
||||
expect(service.hasTemplate('user.registered')).toBe(true);
|
||||
expect(service.hasTemplate('agent.verified')).toBe(true);
|
||||
expect(service.hasTemplate('quota.exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasTemplate returns false for non-existing template', () => {
|
||||
expect(service.hasTemplate('unknown.template')).toBe(false);
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 6 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(6);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
expect(keys).toContain('inquiry.received');
|
||||
expect(keys).toContain('quota.exceeded');
|
||||
expect(keys).toContain('password.reset');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NotificationsController } from '../controllers/notifications.controller';
|
||||
|
||||
describe('NotificationsController', () => {
|
||||
let controller: NotificationsController;
|
||||
let mockNotificationRepo: { findByUserId: ReturnType<typeof vi.fn> };
|
||||
let mockPreferenceRepo: { findByUserId: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||
let mockTemplateService: { getTemplateKeys: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
|
||||
|
||||
const mockNotification = {
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL' as const,
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
status: 'SENT' as const,
|
||||
errorDetail: null,
|
||||
sentAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPreference = {
|
||||
id: 'pref-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL' as const,
|
||||
eventType: 'user.registered',
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockNotificationRepo = { findByUserId: vi.fn() };
|
||||
mockPreferenceRepo = { findByUserId: vi.fn(), upsert: vi.fn() };
|
||||
mockTemplateService = { getTemplateKeys: vi.fn() };
|
||||
|
||||
controller = new NotificationsController(
|
||||
mockNotificationRepo as any,
|
||||
mockPreferenceRepo as any,
|
||||
mockTemplateService as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('getHistory calls notificationRepo.findByUserId with user sub and default limit', async () => {
|
||||
mockNotificationRepo.findByUserId.mockResolvedValue([mockNotification]);
|
||||
|
||||
const result = await controller.getHistory(mockUser as any, undefined);
|
||||
|
||||
expect(mockNotificationRepo.findByUserId).toHaveBeenCalledWith('user-1', 50);
|
||||
expect(result).toEqual([mockNotification]);
|
||||
});
|
||||
|
||||
it('getHistory passes custom limit', async () => {
|
||||
mockNotificationRepo.findByUserId.mockResolvedValue([mockNotification]);
|
||||
|
||||
await controller.getHistory(mockUser as any, 10);
|
||||
|
||||
expect(mockNotificationRepo.findByUserId).toHaveBeenCalledWith('user-1', 10);
|
||||
});
|
||||
|
||||
it('getPreferences calls preferenceRepo.findByUserId', async () => {
|
||||
mockPreferenceRepo.findByUserId.mockResolvedValue([mockPreference]);
|
||||
|
||||
const result = await controller.getPreferences(mockUser as any);
|
||||
|
||||
expect(mockPreferenceRepo.findByUserId).toHaveBeenCalledWith('user-1');
|
||||
expect(result).toEqual([mockPreference]);
|
||||
});
|
||||
|
||||
it('updatePreference calls preferenceRepo.upsert with correct params', async () => {
|
||||
mockPreferenceRepo.upsert.mockResolvedValue({ ...mockPreference, enabled: false });
|
||||
|
||||
const result = await controller.updatePreference(mockUser as any, {
|
||||
channel: 'EMAIL' as any,
|
||||
eventType: 'user.registered',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(mockPreferenceRepo.upsert).toHaveBeenCalledWith('user-1', 'EMAIL', 'user.registered', false);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('getTemplates returns template keys from templateService', async () => {
|
||||
const keys = ['user.registered', 'agent.verified', 'listing.approved', 'inquiry.received', 'quota.exceeded', 'password.reset'];
|
||||
mockTemplateService.getTemplateKeys.mockReturnValue(keys);
|
||||
|
||||
const result = await controller.getTemplates();
|
||||
|
||||
expect(mockTemplateService.getTemplateKeys).toHaveBeenCalled();
|
||||
expect(result).toEqual({ templates: keys });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user