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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user