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:
Ho Ngoc Hai
2026-04-08 23:07:14 +07:00
parent 7fb25eb2b1
commit c9782fd48d
18 changed files with 2097 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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