feat(web): add ErrorBoundary, PageErrorBoundary, ComponentErrorBoundary
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 4s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 35s
Deploy / Build API Image (push) Failing after 15s
Deploy / Build Web Image (push) Failing after 13s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m33s
Security Scanning / Trivy Scan — Web Image (push) Failing after 54s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 45s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 46s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped

Implements GOO-63 audit requirement — React error boundaries with
Vietnamese-language fallback UI, Sentry capture, and "Thử lại" retry.

- ErrorBoundary: generic class component wrapping Sentry.captureException
- PageErrorBoundary: full-page fallback for route layouts
- ComponentErrorBoundary: inline widget fallback (compact + standard modes)
- Applied to ListingMap, CheckoutModal, SearchResults as first targets

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 20:27:06 +07:00
parent 8681eb9aa9
commit 199de240b1
9 changed files with 624 additions and 3 deletions

View File

@@ -0,0 +1,115 @@
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
import { ListingExpiryCronService } from '../../infrastructure/cron/listing-expiry-cron.service';
describe('ListingExpiryCronService', () => {
let service: ListingExpiryCronService;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
mockEventBus = { publish: vi.fn() };
mockLogger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() };
service = new ListingExpiryCronService(
mockPrisma as any,
mockEventBus as any,
mockLogger as any,
);
});
it('publishes ListingExpiringEvent for each expiring listing and logs the count', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-a', expiresAt },
{ id: 'listing-b', sellerId: 'seller-b', expiresAt },
]);
await service.notifyExpiringListings();
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledTimes(2);
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0]).toBeInstanceOf(ListingExpiringEvent);
expect(events[1]).toBeInstanceOf(ListingExpiringEvent);
expect(events.map((e) => e.aggregateId).sort()).toEqual(['listing-a', 'listing-b']);
expect(events[0].eventName).toBe('listing.expiring');
expect(events[0].sellerId).toBe('seller-a');
expect(events[0].expiresAt).toBe(expiresAt);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('2 listing(s)'),
'ListingExpiryCronService',
);
});
it('is a no-op when no listings are expiring (idempotent across runs)', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await service.notifyExpiringListings();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.log).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
'No listings expiring in the next 3 days — nothing to notify',
'ListingExpiryCronService',
);
});
it('catches DB errors and logs them without throwing', async () => {
const boom = new Error('connection lost');
mockPrisma.$queryRaw.mockRejectedValue(boom);
await expect(service.notifyExpiringListings()).resolves.toBeUndefined();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('connection lost'),
expect.any(String),
'ListingExpiryCronService',
);
});
it('uses a single atomic UPDATE ... RETURNING so concurrent runs do not double-notify', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
// Simulate two parallel runs against the same DB; only the first sees the row.
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ id: 'listing-1', sellerId: 'seller-1', expiresAt }])
.mockResolvedValueOnce([]);
await Promise.all([
service.notifyExpiringListings(),
service.notifyExpiringListings(),
]);
// Only one event published across both runs.
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('publishes events with correct sellerId and expiresAt per row', async () => {
const expiresAtA = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000);
const expiresAtB = new Date(Date.now() + 2.5 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-x', expiresAt: expiresAtA },
{ id: 'listing-b', sellerId: 'seller-y', expiresAt: expiresAtB },
]);
await service.notifyExpiringListings();
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0].aggregateId).toBe('listing-a');
expect(events[0].sellerId).toBe('seller-x');
expect(events[0].expiresAt).toBe(expiresAtA);
expect(events[1].aggregateId).toBe('listing-b');
expect(events[1].sellerId).toBe('seller-y');
expect(events[1].expiresAt).toBe(expiresAtB);
});
});

View File

@@ -0,0 +1,174 @@
import { ListingExpiringEvent } from '../../../listings/domain/events/listing-expiring.event';
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { ListingExpiringListener } from '../listeners/listing-expiring.listener';
describe('ListingExpiringListener', () => {
let listener: ListingExpiringListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
};
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new ListingExpiringListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends both email and Zalo OA notifications when seller has email and phone', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ Quận 7' },
seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' },
});
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
const commands = mockCommandBus.execute.mock.calls.map((c) => c[0]) as SendNotificationCommand[];
// Email notification
const emailCmd = commands.find((c) => c.channel === 'EMAIL')!;
expect(emailCmd.userId).toBe('seller-1');
expect(emailCmd.templateKey).toBe('listing.expiring');
expect(emailCmd.recipientAddress).toBe('seller@example.com');
expect(emailCmd.templateData).toEqual(
expect.objectContaining({
listingTitle: 'Căn hộ Quận 7',
expiresAt: expiresAt.toISOString(),
}),
);
// Zalo OA notification
const zaloCmd = commands.find((c) => c.channel === 'ZALO_OA')!;
expect(zaloCmd.userId).toBe('seller-1');
expect(zaloCmd.templateKey).toBe('listing.expiring');
expect(zaloCmd.recipientAddress).toBe('0901234567');
});
it('sends only email when seller has no phone', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Nhà phố Quận 1' },
seller: { id: 'seller-1', email: 'seller@example.com', phone: null },
});
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.channel).toBe('EMAIL');
expect(cmd.recipientAddress).toBe('seller@example.com');
});
it('sends only Zalo OA when seller has no email', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Biệt thự Thảo Điền' },
seller: { id: 'seller-1', email: null, phone: '0909876543' },
});
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.channel).toBe('ZALO_OA');
expect(cmd.recipientAddress).toBe('0909876543');
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const event = new ListingExpiringEvent('nonexistent', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('sends no notifications when seller has neither email nor phone', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ mini' },
seller: { id: 'seller-1', email: null, phone: null },
});
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('includes correct daysRemaining in templateData', async () => {
const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ' },
seller: { id: 'seller-1', email: 'test@example.com', phone: null },
});
const event = new ListingExpiringEvent('listing-1', 'seller-1', twoDaysFromNow);
await listener.handle(event);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.templateData).toHaveProperty('daysRemaining');
expect(cmd.templateData.daysRemaining).toBeGreaterThanOrEqual(1);
expect(cmd.templateData.daysRemaining).toBeLessThanOrEqual(3);
});
it('uses Promise.allSettled so one notification failure does not block the other', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ' },
seller: { id: 'seller-1', email: 'seller@example.com', phone: '0901234567' },
});
// Email fails, Zalo succeeds
mockCommandBus.execute
.mockRejectedValueOnce(new Error('SMTP down'))
.mockResolvedValueOnce(undefined);
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
// Should not throw even though email notification failed
await expect(listener.handle(event)).resolves.toBeUndefined();
// Both notifications were attempted
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
});
it('queries listing with correct include shape', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const event = new ListingExpiringEvent('listing-1', 'seller-1', expiresAt);
await listener.handle(event);
expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith({
where: { id: 'listing-1' },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true, phone: true } },
},
});
});
});