test(web): add middleware + i18n + messages test suites (GOO-60)

Cover frontend middleware auth/locale logic, i18n config/routing/request/navigation,
and vi/en message parity — 91 new tests across 6 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 10:47:30 +07:00
parent 7a854373b3
commit 0924f0cb9b
15 changed files with 1356 additions and 61 deletions

View File

@@ -9,6 +9,7 @@
"start:prod": "node dist/main",
"lint": "eslint src/",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit"
},
@@ -16,6 +17,7 @@
"@anthropic-ai/sdk": "^0.89.0",
"@aws-sdk/client-s3": "^3.1026.0",
"@aws-sdk/s3-request-presigner": "^3.1026.0",
"@goodgo/ai-contract": "workspace:*",
"@goodgo/mcp-servers": "workspace:*",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/bullmq": "^11.0.4",
@@ -84,6 +86,7 @@
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.3",
"prisma": "^7.7.0",
"supertest": "^7.2.2",
"typescript": "^6.0.2",

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

@@ -10,6 +10,29 @@ export default defineConfig({
env: {
BCRYPT_ROUNDS: '4',
},
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
reportsDirectory: './coverage',
include: ['src/modules/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.integration.spec.ts',
'src/**/*.mock.ts',
'src/**/*.module.ts',
'src/**/index.ts',
'src/**/__tests__/**',
'src/**/__mocks__/**',
'src/**/dto/**',
'node_modules',
],
thresholds: {
statements: 60,
branches: 50,
functions: 60,
lines: 60,
},
},
},
resolve: {
alias: {