diff --git a/src/config.test.ts b/src/config.test.ts index 03819ed..2536be7 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -89,56 +89,59 @@ describe('config (client-side)', () => { describe('sentry boolean parsing logic', () => { // These tests verify the parsing logic used in config.ts // by testing the same expressions used there + // Helper to simulate env var parsing (values come as strings at runtime) + const parseDebug = (value: string | undefined): boolean => value === 'true'; + const parseEnabled = (value: string | undefined): boolean => value !== 'false'; describe('debug parsing (=== "true")', () => { it('should return true only when value is exactly "true"', () => { - expect('true' === 'true').toBe(true); + expect(parseDebug('true')).toBe(true); }); it('should return false when value is "false"', () => { - expect('false' === 'true').toBe(false); + expect(parseDebug('false')).toBe(false); }); it('should return false when value is "1"', () => { - expect('1' === 'true').toBe(false); + expect(parseDebug('1')).toBe(false); }); it('should return false when value is empty string', () => { - expect('' === 'true').toBe(false); + expect(parseDebug('')).toBe(false); }); it('should return false when value is undefined', () => { - expect(undefined === 'true').toBe(false); + expect(parseDebug(undefined)).toBe(false); }); it('should return false when value is "TRUE" (case sensitive)', () => { - expect('TRUE' === 'true').toBe(false); + expect(parseDebug('TRUE')).toBe(false); }); }); describe('enabled parsing (!== "false")', () => { it('should return true when value is undefined (default enabled)', () => { - expect(undefined !== 'false').toBe(true); + expect(parseEnabled(undefined)).toBe(true); }); it('should return true when value is empty string', () => { - expect('' !== 'false').toBe(true); + expect(parseEnabled('')).toBe(true); }); it('should return true when value is "true"', () => { - expect('true' !== 'false').toBe(true); + expect(parseEnabled('true')).toBe(true); }); it('should return false only when value is exactly "false"', () => { - expect('false' !== 'false').toBe(false); + expect(parseEnabled('false')).toBe(false); }); it('should return true when value is "FALSE" (case sensitive)', () => { - expect('FALSE' !== 'false').toBe(true); + expect(parseEnabled('FALSE')).toBe(true); }); it('should return true when value is "0"', () => { - expect('0' !== 'false').toBe(true); + expect(parseEnabled('0')).toBe(true); }); }); }); diff --git a/src/config/swagger.test.ts b/src/config/swagger.test.ts index b922216..3b0a434 100644 --- a/src/config/swagger.test.ts +++ b/src/config/swagger.test.ts @@ -2,6 +2,35 @@ import { describe, it, expect } from 'vitest'; import { swaggerSpec } from './swagger'; +// Type definition for OpenAPI 3.0 spec structure used in tests +interface OpenAPISpec { + openapi: string; + info: { + title: string; + version: string; + description?: string; + contact?: { name: string }; + license?: { name: string }; + }; + servers: Array<{ url: string; description?: string }>; + components: { + securitySchemes?: { + bearerAuth?: { + type: string; + scheme: string; + bearerFormat?: string; + description?: string; + }; + }; + schemas?: Record; + }; + tags: Array<{ name: string; description?: string }>; + paths?: Record; +} + +// Cast to typed spec for property access +const spec = swaggerSpec as OpenAPISpec; + /** * Tests for src/config/swagger.ts - OpenAPI/Swagger configuration. * @@ -16,78 +45,80 @@ describe('swagger configuration', () => { }); it('should have openapi version 3.0.0', () => { - expect(swaggerSpec.openapi).toBe('3.0.0'); + expect(spec.openapi).toBe('3.0.0'); }); }); describe('info section', () => { it('should have info object with required fields', () => { - expect(swaggerSpec.info).toBeDefined(); - expect(swaggerSpec.info.title).toBe('Flyer Crawler API'); - expect(swaggerSpec.info.version).toBe('1.0.0'); + expect(spec.info).toBeDefined(); + expect(spec.info.title).toBe('Flyer Crawler API'); + expect(spec.info.version).toBe('1.0.0'); }); it('should have description', () => { - expect(swaggerSpec.info.description).toBeDefined(); - expect(swaggerSpec.info.description).toContain('Flyer Crawler'); + expect(spec.info.description).toBeDefined(); + expect(spec.info.description).toContain('Flyer Crawler'); }); it('should have contact information', () => { - expect(swaggerSpec.info.contact).toBeDefined(); - expect(swaggerSpec.info.contact.name).toBe('API Support'); + expect(spec.info.contact).toBeDefined(); + expect(spec.info.contact?.name).toBe('API Support'); }); it('should have license information', () => { - expect(swaggerSpec.info.license).toBeDefined(); - expect(swaggerSpec.info.license.name).toBe('Private'); + expect(spec.info.license).toBeDefined(); + expect(spec.info.license?.name).toBe('Private'); }); }); describe('servers section', () => { it('should have servers array', () => { - expect(swaggerSpec.servers).toBeDefined(); - expect(Array.isArray(swaggerSpec.servers)).toBe(true); - expect(swaggerSpec.servers.length).toBeGreaterThan(0); + expect(spec.servers).toBeDefined(); + expect(Array.isArray(spec.servers)).toBe(true); + expect(spec.servers.length).toBeGreaterThan(0); }); it('should have /api as the server URL', () => { - const apiServer = swaggerSpec.servers.find((s: { url: string }) => s.url === '/api'); + const apiServer = spec.servers.find((s) => s.url === '/api'); expect(apiServer).toBeDefined(); - expect(apiServer.description).toBe('API server'); + expect(apiServer?.description).toBe('API server'); }); }); describe('components section', () => { it('should have components object', () => { - expect(swaggerSpec.components).toBeDefined(); + expect(spec.components).toBeDefined(); }); describe('securitySchemes', () => { it('should have bearerAuth security scheme', () => { - expect(swaggerSpec.components.securitySchemes).toBeDefined(); - expect(swaggerSpec.components.securitySchemes.bearerAuth).toBeDefined(); + expect(spec.components.securitySchemes).toBeDefined(); + expect(spec.components.securitySchemes?.bearerAuth).toBeDefined(); }); it('should configure bearerAuth as HTTP bearer with JWT format', () => { - const bearerAuth = swaggerSpec.components.securitySchemes.bearerAuth; - expect(bearerAuth.type).toBe('http'); - expect(bearerAuth.scheme).toBe('bearer'); - expect(bearerAuth.bearerFormat).toBe('JWT'); + const bearerAuth = spec.components.securitySchemes?.bearerAuth; + expect(bearerAuth?.type).toBe('http'); + expect(bearerAuth?.scheme).toBe('bearer'); + expect(bearerAuth?.bearerFormat).toBe('JWT'); }); it('should have description for bearerAuth', () => { - const bearerAuth = swaggerSpec.components.securitySchemes.bearerAuth; - expect(bearerAuth.description).toContain('JWT token'); + const bearerAuth = spec.components.securitySchemes?.bearerAuth; + expect(bearerAuth?.description).toContain('JWT token'); }); }); describe('schemas', () => { + const schemas = () => spec.components.schemas as Record; + it('should have schemas object', () => { - expect(swaggerSpec.components.schemas).toBeDefined(); + expect(spec.components.schemas).toBeDefined(); }); it('should have SuccessResponse schema (ADR-028)', () => { - const schema = swaggerSpec.components.schemas.SuccessResponse; + const schema = schemas().SuccessResponse; expect(schema).toBeDefined(); expect(schema.type).toBe('object'); expect(schema.properties.success).toBeDefined(); @@ -97,7 +128,7 @@ describe('swagger configuration', () => { }); it('should have ErrorResponse schema (ADR-028)', () => { - const schema = swaggerSpec.components.schemas.ErrorResponse; + const schema = schemas().ErrorResponse; expect(schema).toBeDefined(); expect(schema.type).toBe('object'); expect(schema.properties.success).toBeDefined(); @@ -107,7 +138,7 @@ describe('swagger configuration', () => { }); it('should have ErrorResponse error object with code and message', () => { - const errorSchema = swaggerSpec.components.schemas.ErrorResponse.properties.error; + const errorSchema = schemas().ErrorResponse.properties.error; expect(errorSchema.properties.code).toBeDefined(); expect(errorSchema.properties.message).toBeDefined(); expect(errorSchema.required).toContain('code'); @@ -115,7 +146,7 @@ describe('swagger configuration', () => { }); it('should have ServiceHealth schema', () => { - const schema = swaggerSpec.components.schemas.ServiceHealth; + const schema = schemas().ServiceHealth; expect(schema).toBeDefined(); expect(schema.type).toBe('object'); expect(schema.properties.status).toBeDefined(); @@ -125,7 +156,7 @@ describe('swagger configuration', () => { }); it('should have Achievement schema', () => { - const schema = swaggerSpec.components.schemas.Achievement; + const schema = schemas().Achievement; expect(schema).toBeDefined(); expect(schema.type).toBe('object'); expect(schema.properties.achievement_id).toBeDefined(); @@ -136,14 +167,14 @@ describe('swagger configuration', () => { }); it('should have UserAchievement schema extending Achievement', () => { - const schema = swaggerSpec.components.schemas.UserAchievement; + const schema = schemas().UserAchievement; expect(schema).toBeDefined(); expect(schema.allOf).toBeDefined(); expect(schema.allOf[0].$ref).toBe('#/components/schemas/Achievement'); }); it('should have LeaderboardUser schema', () => { - const schema = swaggerSpec.components.schemas.LeaderboardUser; + const schema = schemas().LeaderboardUser; expect(schema).toBeDefined(); expect(schema.type).toBe('object'); expect(schema.properties.user_id).toBeDefined(); @@ -156,62 +187,62 @@ describe('swagger configuration', () => { describe('tags section', () => { it('should have tags array', () => { - expect(swaggerSpec.tags).toBeDefined(); - expect(Array.isArray(swaggerSpec.tags)).toBe(true); + expect(spec.tags).toBeDefined(); + expect(Array.isArray(spec.tags)).toBe(true); }); it('should have Health tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Health'); + const tag = spec.tags.find((t) => t.name === 'Health'); expect(tag).toBeDefined(); - expect(tag.description).toContain('health'); + expect(tag?.description).toContain('health'); }); it('should have Auth tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Auth'); + const tag = spec.tags.find((t) => t.name === 'Auth'); expect(tag).toBeDefined(); - expect(tag.description).toContain('Authentication'); + expect(tag?.description).toContain('Authentication'); }); it('should have Users tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Users'); + const tag = spec.tags.find((t) => t.name === 'Users'); expect(tag).toBeDefined(); - expect(tag.description).toContain('User'); + expect(tag?.description).toContain('User'); }); it('should have Achievements tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Achievements'); + const tag = spec.tags.find((t) => t.name === 'Achievements'); expect(tag).toBeDefined(); - expect(tag.description).toContain('Gamification'); + expect(tag?.description).toContain('Gamification'); }); it('should have Flyers tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Flyers'); + const tag = spec.tags.find((t) => t.name === 'Flyers'); expect(tag).toBeDefined(); }); it('should have Recipes tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Recipes'); + const tag = spec.tags.find((t) => t.name === 'Recipes'); expect(tag).toBeDefined(); }); it('should have Budgets tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Budgets'); + const tag = spec.tags.find((t) => t.name === 'Budgets'); expect(tag).toBeDefined(); }); it('should have Admin tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'Admin'); + const tag = spec.tags.find((t) => t.name === 'Admin'); expect(tag).toBeDefined(); - expect(tag.description).toContain('admin'); + expect(tag?.description).toContain('admin'); }); it('should have System tag', () => { - const tag = swaggerSpec.tags.find((t: { name: string }) => t.name === 'System'); + const tag = spec.tags.find((t) => t.name === 'System'); expect(tag).toBeDefined(); }); it('should have 9 tags total', () => { - expect(swaggerSpec.tags.length).toBe(9); + expect(spec.tags.length).toBe(9); }); }); diff --git a/src/services/cacheService.server.test.ts b/src/services/cacheService.server.test.ts new file mode 100644 index 0000000..644a326 --- /dev/null +++ b/src/services/cacheService.server.test.ts @@ -0,0 +1,349 @@ +// src/services/cacheService.server.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Use vi.hoisted to ensure mockRedis is available before vi.mock runs +const { mockRedis } = vi.hoisted(() => ({ + mockRedis: { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + scan: vi.fn(), + }, +})); + +vi.mock('./redis.server', () => ({ + connection: mockRedis, +})); + +// Mock logger +vi.mock('./logger.server', async () => ({ + logger: (await import('../tests/utils/mockLogger')).mockLogger, +})); + +import { cacheService, CACHE_TTL, CACHE_PREFIX } from './cacheService.server'; +import { logger } from './logger.server'; + +describe('cacheService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('CACHE_TTL constants', () => { + it('should have BRANDS TTL of 1 hour', () => { + expect(CACHE_TTL.BRANDS).toBe(60 * 60); + }); + + it('should have FLYERS TTL of 5 minutes', () => { + expect(CACHE_TTL.FLYERS).toBe(5 * 60); + }); + + it('should have FLYER TTL of 10 minutes', () => { + expect(CACHE_TTL.FLYER).toBe(10 * 60); + }); + + it('should have FLYER_ITEMS TTL of 10 minutes', () => { + expect(CACHE_TTL.FLYER_ITEMS).toBe(10 * 60); + }); + + it('should have STATS TTL of 5 minutes', () => { + expect(CACHE_TTL.STATS).toBe(5 * 60); + }); + + it('should have FREQUENT_SALES TTL of 15 minutes', () => { + expect(CACHE_TTL.FREQUENT_SALES).toBe(15 * 60); + }); + + it('should have CATEGORIES TTL of 1 hour', () => { + expect(CACHE_TTL.CATEGORIES).toBe(60 * 60); + }); + }); + + describe('CACHE_PREFIX constants', () => { + it('should have correct prefix values', () => { + expect(CACHE_PREFIX.BRANDS).toBe('cache:brands'); + expect(CACHE_PREFIX.FLYERS).toBe('cache:flyers'); + expect(CACHE_PREFIX.FLYER).toBe('cache:flyer'); + expect(CACHE_PREFIX.FLYER_ITEMS).toBe('cache:flyer-items'); + expect(CACHE_PREFIX.STATS).toBe('cache:stats'); + expect(CACHE_PREFIX.FREQUENT_SALES).toBe('cache:frequent-sales'); + expect(CACHE_PREFIX.CATEGORIES).toBe('cache:categories'); + }); + }); + + describe('get', () => { + it('should return parsed JSON on cache hit', async () => { + const testData = { foo: 'bar', count: 42 }; + mockRedis.get.mockResolvedValue(JSON.stringify(testData)); + + const result = await cacheService.get('test-key'); + + expect(result).toEqual(testData); + expect(mockRedis.get).toHaveBeenCalledWith('test-key'); + expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache hit'); + }); + + it('should return null on cache miss', async () => { + mockRedis.get.mockResolvedValue(null); + + const result = await cacheService.get('test-key'); + + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss'); + }); + + it('should return null and log warning on Redis error', async () => { + const error = new Error('Redis connection failed'); + mockRedis.get.mockRejectedValue(error); + + const result = await cacheService.get('test-key'); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + { err: error, cacheKey: 'test-key' }, + 'Redis GET failed, proceeding without cache', + ); + }); + + it('should use provided logger', async () => { + const customLogger = { + debug: vi.fn(), + warn: vi.fn(), + } as any; + mockRedis.get.mockResolvedValue(null); + + await cacheService.get('test-key', customLogger); + + expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss'); + }); + }); + + describe('set', () => { + it('should store JSON stringified value with TTL', async () => { + const testData = { foo: 'bar' }; + mockRedis.set.mockResolvedValue('OK'); + + await cacheService.set('test-key', testData, 300); + + expect(mockRedis.set).toHaveBeenCalledWith('test-key', JSON.stringify(testData), 'EX', 300); + expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key', ttl: 300 }, 'Value cached'); + }); + + it('should log warning on Redis error', async () => { + const error = new Error('Redis write failed'); + mockRedis.set.mockRejectedValue(error); + + await cacheService.set('test-key', { data: 'value' }, 300); + + expect(logger.warn).toHaveBeenCalledWith( + { err: error, cacheKey: 'test-key' }, + 'Redis SET failed, value not cached', + ); + }); + + it('should use provided logger', async () => { + const customLogger = { + debug: vi.fn(), + warn: vi.fn(), + } as any; + mockRedis.set.mockResolvedValue('OK'); + + await cacheService.set('test-key', 'value', 300, customLogger); + + expect(customLogger.debug).toHaveBeenCalledWith( + { cacheKey: 'test-key', ttl: 300 }, + 'Value cached', + ); + }); + }); + + describe('del', () => { + it('should delete key from cache', async () => { + mockRedis.del.mockResolvedValue(1); + + await cacheService.del('test-key'); + + expect(mockRedis.del).toHaveBeenCalledWith('test-key'); + expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache key deleted'); + }); + + it('should log warning on Redis error', async () => { + const error = new Error('Redis delete failed'); + mockRedis.del.mockRejectedValue(error); + + await cacheService.del('test-key'); + + expect(logger.warn).toHaveBeenCalledWith( + { err: error, cacheKey: 'test-key' }, + 'Redis DEL failed', + ); + }); + + it('should use provided logger', async () => { + const customLogger = { + debug: vi.fn(), + warn: vi.fn(), + } as any; + mockRedis.del.mockResolvedValue(1); + + await cacheService.del('test-key', customLogger); + + expect(customLogger.debug).toHaveBeenCalledWith( + { cacheKey: 'test-key' }, + 'Cache key deleted', + ); + }); + }); + + describe('invalidatePattern', () => { + it('should scan and delete keys matching pattern', async () => { + // First scan returns some keys, second scan returns cursor '0' to stop + mockRedis.scan + .mockResolvedValueOnce(['1', ['cache:test:1', 'cache:test:2']]) + .mockResolvedValueOnce(['0', ['cache:test:3']]); + mockRedis.del.mockResolvedValue(2).mockResolvedValueOnce(2).mockResolvedValueOnce(1); + + const result = await cacheService.invalidatePattern('cache:test:*'); + + expect(result).toBe(3); + expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:test:*', 'COUNT', 100); + expect(mockRedis.del).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + { pattern: 'cache:test:*', totalDeleted: 3 }, + 'Cache invalidation completed', + ); + }); + + it('should handle empty scan results', async () => { + mockRedis.scan.mockResolvedValue(['0', []]); + + const result = await cacheService.invalidatePattern('cache:empty:*'); + + expect(result).toBe(0); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('should throw and log error on Redis failure', async () => { + const error = new Error('Redis scan failed'); + mockRedis.scan.mockRejectedValue(error); + + await expect(cacheService.invalidatePattern('cache:test:*')).rejects.toThrow(error); + expect(logger.error).toHaveBeenCalledWith( + { err: error, pattern: 'cache:test:*' }, + 'Cache invalidation failed', + ); + }); + }); + + describe('getOrSet', () => { + it('should return cached value on cache hit', async () => { + const cachedData = { id: 1, name: 'Test' }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedData)); + const fetcher = vi.fn(); + + const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 }); + + expect(result).toEqual(cachedData); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('should call fetcher and cache result on cache miss', async () => { + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); + const freshData = { id: 2, name: 'Fresh' }; + const fetcher = vi.fn().mockResolvedValue(freshData); + + const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 }); + + expect(result).toEqual(freshData); + expect(fetcher).toHaveBeenCalled(); + // set is fire-and-forget, but we can verify it was called + await vi.waitFor(() => { + expect(mockRedis.set).toHaveBeenCalledWith( + 'test-key', + JSON.stringify(freshData), + 'EX', + 300, + ); + }); + }); + + it('should use provided logger from options', async () => { + const customLogger = { + debug: vi.fn(), + warn: vi.fn(), + } as any; + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); + const fetcher = vi.fn().mockResolvedValue({ data: 'value' }); + + await cacheService.getOrSet('test-key', fetcher, { ttl: 300, logger: customLogger }); + + expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss'); + }); + + it('should not throw if set fails after fetching', async () => { + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockRejectedValue(new Error('Redis write failed')); + const freshData = { id: 3, name: 'Data' }; + const fetcher = vi.fn().mockResolvedValue(freshData); + + // Should not throw - set failures are caught internally + const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 }); + + expect(result).toEqual(freshData); + }); + }); + + describe('invalidateBrands', () => { + it('should invalidate all brand cache entries', async () => { + mockRedis.scan.mockResolvedValue(['0', ['cache:brands:1', 'cache:brands:2']]); + mockRedis.del.mockResolvedValue(2); + + const result = await cacheService.invalidateBrands(); + + expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:brands*', 'COUNT', 100); + expect(result).toBe(2); + }); + }); + + describe('invalidateFlyers', () => { + it('should invalidate all flyer-related cache entries', async () => { + // Mock scan for each pattern + mockRedis.scan + .mockResolvedValueOnce(['0', ['cache:flyers:list']]) + .mockResolvedValueOnce(['0', ['cache:flyer:1', 'cache:flyer:2']]) + .mockResolvedValueOnce(['0', ['cache:flyer-items:1']]); + mockRedis.del.mockResolvedValueOnce(1).mockResolvedValueOnce(2).mockResolvedValueOnce(1); + + const result = await cacheService.invalidateFlyers(); + + expect(result).toBe(4); + expect(mockRedis.scan).toHaveBeenCalledTimes(3); + }); + }); + + describe('invalidateFlyer', () => { + it('should invalidate specific flyer and its items', async () => { + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValue(['0', []]); + + await cacheService.invalidateFlyer(123); + + expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer:123'); + expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer-items:123'); + expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:flyers*', 'COUNT', 100); + }); + }); + + describe('invalidateStats', () => { + it('should invalidate all stats cache entries', async () => { + mockRedis.scan.mockResolvedValue(['0', ['cache:stats:daily', 'cache:stats:weekly']]); + mockRedis.del.mockResolvedValue(2); + + const result = await cacheService.invalidateStats(); + + expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:stats*', 'COUNT', 100); + expect(result).toBe(2); + }); + }); +}); diff --git a/src/services/logger.server.test.ts b/src/services/logger.server.test.ts index 62271bf..2ee8bdb 100644 --- a/src/services/logger.server.test.ts +++ b/src/services/logger.server.test.ts @@ -124,4 +124,171 @@ describe('Server Logger', () => { mockMultistream, ); }); + + it('should use LOG_DIR environment variable when set', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('LOG_DIR', '/custom/log/dir'); + await import('./logger.server'); + + // Should use the custom LOG_DIR in the file path + expect(mockDestination).toHaveBeenCalledWith( + expect.objectContaining({ + dest: '/custom/log/dir/app.log', + }), + ); + }); + + it('should fall back to stdout only when log directory creation fails', async () => { + vi.stubEnv('NODE_ENV', 'production'); + + // Mock fs.existsSync to return false (dir doesn't exist) + // and mkdirSync to throw an error + const fs = await import('fs'); + vi.mocked(fs.default.existsSync).mockReturnValue(false); + vi.mocked(fs.default.mkdirSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Suppress console.error during this test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await import('./logger.server'); + + // Should have tried to create directory + expect(fs.default.mkdirSync).toHaveBeenCalled(); + + // Should log error to console + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to create log directory'), + expect.any(Error), + ); + + // Should fall back to stdout-only logger (no multistream) + // When logDir is null, pino is called without multistream + expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' })); + + consoleErrorSpy.mockRestore(); + }); + + describe('createScopedLogger', () => { + it('should create a child logger with module name', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const { createScopedLogger } = await import('./logger.server'); + + const scopedLogger = createScopedLogger('test-module'); + + expect(mockLoggerInstance.child).toHaveBeenCalledWith( + expect.objectContaining({ module: 'test-module' }), + ); + expect(scopedLogger).toBeDefined(); + }); + + it('should enable debug level when DEBUG_MODULES includes module name', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('DEBUG_MODULES', 'test-module,other-module'); + const { createScopedLogger } = await import('./logger.server'); + + createScopedLogger('test-module'); + + expect(mockLoggerInstance.child).toHaveBeenCalledWith( + expect.objectContaining({ + module: 'test-module', + level: 'debug', + }), + ); + }); + + it('should enable debug level when DEBUG_MODULES includes wildcard', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('DEBUG_MODULES', '*'); + const { createScopedLogger } = await import('./logger.server'); + + createScopedLogger('any-module'); + + expect(mockLoggerInstance.child).toHaveBeenCalledWith( + expect.objectContaining({ + module: 'any-module', + level: 'debug', + }), + ); + }); + + it('should use default level when module not in DEBUG_MODULES', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('DEBUG_MODULES', 'other-module'); + const { createScopedLogger } = await import('./logger.server'); + + createScopedLogger('test-module'); + + expect(mockLoggerInstance.child).toHaveBeenCalledWith( + expect.objectContaining({ + module: 'test-module', + level: 'info', // Uses logger.level which is 'info' + }), + ); + }); + + it('should handle empty DEBUG_MODULES', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('DEBUG_MODULES', ''); + const { createScopedLogger } = await import('./logger.server'); + + createScopedLogger('test-module'); + + expect(mockLoggerInstance.child).toHaveBeenCalledWith( + expect.objectContaining({ + module: 'test-module', + level: 'info', + }), + ); + }); + }); + + describe('redaction configuration', () => { + it('should configure redaction for sensitive fields', async () => { + // Reset fs mock to ensure directory creation succeeds + const fs = await import('fs'); + vi.mocked(fs.default.existsSync).mockReturnValue(true); + + vi.stubEnv('NODE_ENV', 'production'); + await import('./logger.server'); + + // Verify redact configuration is passed to pino + // When log directory exists, pino is called with config and multistream + expect(pinoMock).toHaveBeenCalledWith( + expect.objectContaining({ + redact: expect.objectContaining({ + paths: expect.arrayContaining([ + 'req.headers.authorization', + 'req.headers.cookie', + '*.body.password', + '*.body.newPassword', + '*.body.currentPassword', + '*.body.confirmPassword', + '*.body.refreshToken', + '*.body.token', + ]), + censor: '[REDACTED]', + }), + }), + expect.anything(), + ); + }); + }); + + describe('environment detection', () => { + it('should treat undefined NODE_ENV as development', async () => { + vi.stubEnv('NODE_ENV', ''); + await import('./logger.server'); + + // Development uses pino-pretty transport + expect(pinoMock).toHaveBeenCalledWith( + expect.objectContaining({ + transport: expect.objectContaining({ + target: 'pino-pretty', + }), + }), + ); + }); + }); }); diff --git a/src/services/receiptService.server.test.ts b/src/services/receiptService.server.test.ts index 2c52011..6ddeeac 100644 --- a/src/services/receiptService.server.test.ts +++ b/src/services/receiptService.server.test.ts @@ -787,5 +787,252 @@ describe('receiptService.server', () => { expect.any(Object), ); }); + + it('should handle error when updating receipt status fails after processing error', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + // First call returns receipt, then processReceipt calls it internally + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + + // All updateReceipt calls fail + vi.mocked(receiptRepo.updateReceipt).mockRejectedValue(new Error('Database unavailable')); + + vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + + const mockJob = { + id: 'job-4', + data: { + receiptId: 1, + userId: 'user-1', + }, + attemptsMade: 1, + } as Job; + + // When all updateReceipt calls fail, the error is propagated + await expect(processReceiptJob(mockJob, mockLogger)).rejects.toThrow('Database unavailable'); + }); + }); + + // Test internal logic patterns used in the service + describe('receipt text parsing patterns', () => { + // These test the regex patterns and logic used in parseReceiptText + + it('should match price pattern at end of line', () => { + const pricePattern = /\$?(\d+)\.(\d{2})\s*$/; + + expect('MILK 2% $4.99'.match(pricePattern)).toBeTruthy(); + expect('BREAD 2.49'.match(pricePattern)).toBeTruthy(); + expect('Item Name $12.00'.match(pricePattern)).toBeTruthy(); + expect('No price here'.match(pricePattern)).toBeNull(); + }); + + it('should match quantity pattern', () => { + const quantityPattern = /^(\d+)\s*[@xX]/; + + expect('2 @ $3.99 APPLES'.match(quantityPattern)?.[1]).toBe('2'); + expect('3x Bananas'.match(quantityPattern)?.[1]).toBe('3'); + expect('5X ITEM'.match(quantityPattern)?.[1]).toBe('5'); + expect('Regular Item'.match(quantityPattern)).toBeNull(); + }); + + it('should identify discount lines', () => { + const isDiscount = (line: string) => + line.includes('-') || line.toLowerCase().includes('discount'); + + expect(isDiscount('COUPON DISCOUNT -$2.00')).toBe(true); + expect(isDiscount('MEMBER DISCOUNT')).toBe(true); + expect(isDiscount('-$1.50')).toBe(true); + expect(isDiscount('Regular Item $4.99')).toBe(false); + }); + }); + + describe('receipt header/footer detection patterns', () => { + // Test the isHeaderOrFooter logic + const skipPatterns = [ + 'thank you', + 'thanks for', + 'visit us', + 'total', + 'subtotal', + 'tax', + 'change', + 'cash', + 'credit', + 'debit', + 'visa', + 'mastercard', + 'approved', + 'transaction', + 'terminal', + 'receipt', + 'store #', + 'date:', + 'time:', + 'cashier', + ]; + + const isHeaderOrFooter = (line: string): boolean => { + const lowercaseLine = line.toLowerCase(); + return skipPatterns.some((pattern) => lowercaseLine.includes(pattern)); + }; + + it('should skip thank you lines', () => { + expect(isHeaderOrFooter('THANK YOU FOR SHOPPING')).toBe(true); + expect(isHeaderOrFooter('Thanks for visiting!')).toBe(true); + }); + + it('should skip total/subtotal lines', () => { + expect(isHeaderOrFooter('SUBTOTAL $45.99')).toBe(true); + expect(isHeaderOrFooter('TOTAL $49.99')).toBe(true); + expect(isHeaderOrFooter('TAX $3.00')).toBe(true); + }); + + it('should skip payment method lines', () => { + expect(isHeaderOrFooter('VISA **** 1234')).toBe(true); + expect(isHeaderOrFooter('MASTERCARD APPROVED')).toBe(true); + expect(isHeaderOrFooter('CASH TENDERED')).toBe(true); + expect(isHeaderOrFooter('CREDIT CARD')).toBe(true); + expect(isHeaderOrFooter('DEBIT $50.00')).toBe(true); + }); + + it('should skip store info lines', () => { + expect(isHeaderOrFooter('Store #1234')).toBe(true); + expect(isHeaderOrFooter('DATE: 01/15/2024')).toBe(true); + expect(isHeaderOrFooter('TIME: 14:30')).toBe(true); + expect(isHeaderOrFooter('Cashier: John')).toBe(true); + }); + + it('should allow regular item lines', () => { + expect(isHeaderOrFooter('MILK 2% $4.99')).toBe(false); + expect(isHeaderOrFooter('BREAD WHOLE WHEAT')).toBe(false); + expect(isHeaderOrFooter('BANANAS 2.5LB')).toBe(false); + }); + }); + + describe('receipt metadata extraction patterns', () => { + // Test the extractReceiptMetadata logic + + it('should extract total amount from different formats', () => { + const totalPatterns = [ + /total[:\s]+\$?(\d+)\.(\d{2})/i, + /grand total[:\s]+\$?(\d+)\.(\d{2})/i, + /amount due[:\s]+\$?(\d+)\.(\d{2})/i, + ]; + + const extractTotal = (text: string): number | undefined => { + for (const pattern of totalPatterns) { + const match = text.match(pattern); + if (match) { + return parseInt(match[1], 10) * 100 + parseInt(match[2], 10); + } + } + return undefined; + }; + + expect(extractTotal('TOTAL: $45.99')).toBe(4599); + expect(extractTotal('Grand Total $123.00')).toBe(12300); + expect(extractTotal('AMOUNT DUE: 78.50')).toBe(7850); + expect(extractTotal('No total here')).toBeUndefined(); + }); + + it('should extract date from MM/DD/YYYY format', () => { + const datePattern = /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/; + + const match1 = '01/15/2024'.match(datePattern); + expect(match1?.[1]).toBe('01'); + expect(match1?.[2]).toBe('15'); + expect(match1?.[3]).toBe('2024'); + + const match2 = '1/5/24'.match(datePattern); + expect(match2?.[1]).toBe('1'); + expect(match2?.[2]).toBe('5'); + expect(match2?.[3]).toBe('24'); + }); + + it('should extract date from YYYY-MM-DD format', () => { + const datePattern = /(\d{4})-(\d{2})-(\d{2})/; + + const match = '2024-01-15'.match(datePattern); + expect(match?.[1]).toBe('2024'); + expect(match?.[2]).toBe('01'); + expect(match?.[3]).toBe('15'); + }); + + it('should convert 2-digit years to 4-digit years', () => { + const convertYear = (year: number): number => { + if (year < 100) { + return year + 2000; + } + return year; + }; + + expect(convertYear(24)).toBe(2024); + expect(convertYear(99)).toBe(2099); + expect(convertYear(2024)).toBe(2024); + }); + }); + + describe('OCR extraction edge cases', () => { + // These test the logic in performOcrExtraction + + it('should determine if URL is local path', () => { + const isLocalPath = (url: string) => !url.startsWith('http'); + + expect(isLocalPath('/uploads/receipt.jpg')).toBe(true); + expect(isLocalPath('./images/receipt.png')).toBe(true); + expect(isLocalPath('https://example.com/receipt.jpg')).toBe(false); + expect(isLocalPath('http://localhost/receipt.jpg')).toBe(false); + }); + + it('should determine MIME type from extension', () => { + const mimeTypeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + }; + + const getMimeType = (ext: string) => mimeTypeMap[ext] || 'image/jpeg'; + + expect(getMimeType('.jpg')).toBe('image/jpeg'); + expect(getMimeType('.jpeg')).toBe('image/jpeg'); + expect(getMimeType('.png')).toBe('image/png'); + expect(getMimeType('.gif')).toBe('image/gif'); + expect(getMimeType('.webp')).toBe('image/webp'); + expect(getMimeType('.unknown')).toBe('image/jpeg'); + }); + + it('should format extracted items as text', () => { + const extractedItems = [ + { raw_item_description: 'MILK 2%', price_paid_cents: 499 }, + { raw_item_description: 'BREAD', price_paid_cents: 299 }, + ]; + + const textLines = extractedItems.map( + (item) => `${item.raw_item_description} - $${(item.price_paid_cents / 100).toFixed(2)}`, + ); + + expect(textLines).toEqual(['MILK 2% - $4.99', 'BREAD - $2.99']); + }); }); }); diff --git a/src/services/sentry.client.test.ts b/src/services/sentry.client.test.ts new file mode 100644 index 0000000..c9c2aa6 --- /dev/null +++ b/src/services/sentry.client.test.ts @@ -0,0 +1,300 @@ +// src/services/sentry.client.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Use vi.hoisted to define mocks that need to be available before vi.mock runs +const { mockSentry, mockLogger } = vi.hoisted(() => ({ + mockSentry: { + init: vi.fn(), + captureException: vi.fn(() => 'mock-event-id'), + captureMessage: vi.fn(() => 'mock-message-id'), + setContext: vi.fn(), + setUser: vi.fn(), + addBreadcrumb: vi.fn(), + breadcrumbsIntegration: vi.fn(() => ({})), + ErrorBoundary: vi.fn(), + }, + mockLogger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@sentry/react', () => mockSentry); + +vi.mock('./logger.client', () => ({ + logger: mockLogger, + default: mockLogger, +})); + +describe('sentry.client', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('with Sentry disabled (default test environment)', () => { + // The test environment has Sentry disabled by default (VITE_SENTRY_DSN not set) + // Import the module fresh for each test + + beforeEach(() => { + vi.resetModules(); + }); + + it('should have isSentryConfigured as false in test environment', async () => { + const { isSentryConfigured } = await import('./sentry.client'); + expect(isSentryConfigured).toBe(false); + }); + + it('should not initialize Sentry when not configured', async () => { + const { initSentry, isSentryConfigured } = await import('./sentry.client'); + + initSentry(); + + // When Sentry is not configured, Sentry.init should NOT be called + if (!isSentryConfigured) { + expect(mockSentry.init).not.toHaveBeenCalled(); + } + }); + + it('should return undefined from captureException when not configured', async () => { + const { captureException } = await import('./sentry.client'); + + const result = captureException(new Error('test error')); + + expect(result).toBeUndefined(); + expect(mockSentry.captureException).not.toHaveBeenCalled(); + }); + + it('should return undefined from captureMessage when not configured', async () => { + const { captureMessage } = await import('./sentry.client'); + + const result = captureMessage('test message'); + + expect(result).toBeUndefined(); + expect(mockSentry.captureMessage).not.toHaveBeenCalled(); + }); + + it('should not set user when not configured', async () => { + const { setUser } = await import('./sentry.client'); + + setUser({ id: '123', email: 'test@example.com' }); + + expect(mockSentry.setUser).not.toHaveBeenCalled(); + }); + + it('should not add breadcrumb when not configured', async () => { + const { addBreadcrumb } = await import('./sentry.client'); + + addBreadcrumb({ message: 'test breadcrumb', category: 'test' }); + + expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled(); + }); + }); + + describe('Sentry re-export', () => { + it('should re-export Sentry object', async () => { + const { Sentry } = await import('./sentry.client'); + + expect(Sentry).toBeDefined(); + expect(Sentry.init).toBeDefined(); + expect(Sentry.captureException).toBeDefined(); + }); + }); + + describe('initSentry beforeSend filter logic', () => { + // Test the beforeSend filter function logic in isolation + // This tests the filter that's passed to Sentry.init + + it('should filter out browser extension errors', () => { + // Simulate the beforeSend logic from the implementation + const filterExtensionErrors = (event: { + exception?: { + values?: Array<{ + stacktrace?: { + frames?: Array<{ filename?: string }>; + }; + }>; + }; + }) => { + if ( + event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) => + frame.filename?.includes('extension://'), + ) + ) { + return null; + } + return event; + }; + + const extensionError = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'chrome-extension://abc123/script.js' }], + }, + }, + ], + }, + }; + + expect(filterExtensionErrors(extensionError)).toBeNull(); + }); + + it('should allow normal errors through', () => { + const filterExtensionErrors = (event: { + exception?: { + values?: Array<{ + stacktrace?: { + frames?: Array<{ filename?: string }>; + }; + }>; + }; + }) => { + if ( + event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) => + frame.filename?.includes('extension://'), + ) + ) { + return null; + } + return event; + }; + + const normalError = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: '/app/src/index.js' }], + }, + }, + ], + }, + }; + + expect(filterExtensionErrors(normalError)).toBe(normalError); + }); + + it('should handle events without exception property', () => { + const filterExtensionErrors = (event: { + exception?: { + values?: Array<{ + stacktrace?: { + frames?: Array<{ filename?: string }>; + }; + }>; + }; + }) => { + if ( + event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) => + frame.filename?.includes('extension://'), + ) + ) { + return null; + } + return event; + }; + + const eventWithoutException = { message: 'test' }; + + expect(filterExtensionErrors(eventWithoutException as any)).toBe(eventWithoutException); + }); + + it('should handle firefox extension URLs', () => { + const filterExtensionErrors = (event: { + exception?: { + values?: Array<{ + stacktrace?: { + frames?: Array<{ filename?: string }>; + }; + }>; + }; + }) => { + if ( + event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) => + frame.filename?.includes('extension://'), + ) + ) { + return null; + } + return event; + }; + + const firefoxExtensionError = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'moz-extension://abc123/script.js' }], + }, + }, + ], + }, + }; + + expect(filterExtensionErrors(firefoxExtensionError)).toBeNull(); + }); + }); + + describe('isSentryConfigured logic', () => { + // Test the logic that determines if Sentry is configured + // This mirrors the implementation: !!config.sentry.dsn && config.sentry.enabled + + it('should return false when DSN is empty', () => { + const dsn = ''; + const enabled = true; + const result = !!dsn && enabled; + expect(result).toBe(false); + }); + + it('should return false when enabled is false', () => { + const dsn = 'https://test@sentry.io/123'; + const enabled = false; + const result = !!dsn && enabled; + expect(result).toBe(false); + }); + + it('should return true when DSN is set and enabled is true', () => { + const dsn = 'https://test@sentry.io/123'; + const enabled = true; + const result = !!dsn && enabled; + expect(result).toBe(true); + }); + + it('should return false when DSN is undefined', () => { + const dsn = undefined; + const enabled = true; + const result = !!dsn && enabled; + expect(result).toBe(false); + }); + }); + + describe('captureException logic', () => { + it('should set context before capturing when context is provided', () => { + // This tests the conditional context setting logic + const context = { userId: '123' }; + const shouldSetContext = !!context; + expect(shouldSetContext).toBe(true); + }); + + it('should not set context when not provided', () => { + const context = undefined; + const shouldSetContext = !!context; + expect(shouldSetContext).toBe(false); + }); + }); + + describe('captureMessage default level', () => { + it('should default to info level', () => { + // Test the default parameter behavior + const defaultLevel = 'info'; + expect(defaultLevel).toBe('info'); + }); + }); +}); diff --git a/src/services/sentry.server.test.ts b/src/services/sentry.server.test.ts new file mode 100644 index 0000000..97fe4e3 --- /dev/null +++ b/src/services/sentry.server.test.ts @@ -0,0 +1,338 @@ +// src/services/sentry.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; + +// Use vi.hoisted to define mocks that need to be available before vi.mock runs +const { mockSentry, mockLogger } = vi.hoisted(() => ({ + mockSentry: { + init: vi.fn(), + captureException: vi.fn(() => 'mock-event-id'), + captureMessage: vi.fn(() => 'mock-message-id'), + setContext: vi.fn(), + setUser: vi.fn(), + addBreadcrumb: vi.fn(), + }, + mockLogger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@sentry/node', () => mockSentry); + +vi.mock('./logger.server', () => ({ + logger: mockLogger, +})); + +// Mock config/env module - by default isSentryConfigured is false and isTest is true +vi.mock('../config/env', () => ({ + config: { + sentry: { + dsn: '', + environment: 'test', + debug: false, + }, + server: { + nodeEnv: 'test', + }, + }, + isSentryConfigured: false, + isProduction: false, + isTest: true, +})); + +describe('sentry.server', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('with Sentry disabled (default test environment)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should not initialize Sentry when not configured', async () => { + const { initSentry } = await import('./sentry.server'); + + initSentry(); + + // Sentry.init should NOT be called when DSN is not configured + expect(mockSentry.init).not.toHaveBeenCalled(); + }); + + it('should return null from captureException when not configured', async () => { + const { captureException } = await import('./sentry.server'); + + const result = captureException(new Error('test error')); + + expect(result).toBeNull(); + expect(mockSentry.captureException).not.toHaveBeenCalled(); + }); + + it('should return null from captureMessage when not configured', async () => { + const { captureMessage } = await import('./sentry.server'); + + const result = captureMessage('test message'); + + expect(result).toBeNull(); + expect(mockSentry.captureMessage).not.toHaveBeenCalled(); + }); + + it('should not set user when not configured', async () => { + const { setUser } = await import('./sentry.server'); + + setUser({ id: '123', email: 'test@example.com' }); + + expect(mockSentry.setUser).not.toHaveBeenCalled(); + }); + + it('should not add breadcrumb when not configured', async () => { + const { addBreadcrumb } = await import('./sentry.server'); + + addBreadcrumb({ message: 'test breadcrumb', category: 'test' }); + + expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled(); + }); + }); + + describe('Sentry re-export', () => { + it('should re-export Sentry object', async () => { + const { Sentry } = await import('./sentry.server'); + + expect(Sentry).toBeDefined(); + expect(Sentry.init).toBeDefined(); + expect(Sentry.captureException).toBeDefined(); + }); + }); + + describe('getSentryMiddleware', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should return no-op middleware when Sentry is not configured', async () => { + const { getSentryMiddleware } = await import('./sentry.server'); + + const middleware = getSentryMiddleware(); + + expect(middleware.requestHandler).toBeDefined(); + expect(middleware.errorHandler).toBeDefined(); + }); + + it('should have requestHandler that calls next()', async () => { + const { getSentryMiddleware } = await import('./sentry.server'); + const middleware = getSentryMiddleware(); + + const req = {} as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + middleware.requestHandler(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('should have errorHandler that passes error to next()', async () => { + const { getSentryMiddleware } = await import('./sentry.server'); + const middleware = getSentryMiddleware(); + + const error = new Error('test error'); + const req = {} as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + middleware.errorHandler(error, req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('initSentry beforeSend logic', () => { + // Test the beforeSend logic in isolation + it('should return event from beforeSend', () => { + // Simulate the beforeSend logic when isProduction is true + const isProduction = true; + const mockEvent = { event_id: '123' }; + + const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => { + // In development, log errors - but don't do extra processing + if (!isProduction && hint.originalException) { + // Would log here in real implementation + } + return event; + }; + + const result = beforeSend(mockEvent, {}); + + expect(result).toBe(mockEvent); + }); + + it('should return event in development with original exception', () => { + // Simulate the beforeSend logic when isProduction is false + const isProduction = false; + const mockEvent = { event_id: '123' }; + const mockException = new Error('test'); + + const beforeSend = (event: { event_id: string }, hint: { originalException?: Error }) => { + if (!isProduction && hint.originalException) { + // Would log here in real implementation + } + return event; + }; + + const result = beforeSend(mockEvent, { originalException: mockException }); + + expect(result).toBe(mockEvent); + }); + }); + + describe('error handler status code logic', () => { + // Test the error handler's status code filtering logic in isolation + + it('should identify 5xx errors for Sentry capture', () => { + // Test the logic that determines if an error should be captured + const shouldCapture = (statusCode: number) => statusCode >= 500; + + expect(shouldCapture(500)).toBe(true); + expect(shouldCapture(502)).toBe(true); + expect(shouldCapture(503)).toBe(true); + }); + + it('should not capture 4xx errors', () => { + const shouldCapture = (statusCode: number) => statusCode >= 500; + + expect(shouldCapture(400)).toBe(false); + expect(shouldCapture(401)).toBe(false); + expect(shouldCapture(403)).toBe(false); + expect(shouldCapture(404)).toBe(false); + expect(shouldCapture(422)).toBe(false); + }); + + it('should extract statusCode from error object', () => { + // Test the status code extraction logic + const getStatusCode = (err: Error & { statusCode?: number; status?: number }) => + err.statusCode || err.status || 500; + + const errorWithStatusCode = Object.assign(new Error('test'), { statusCode: 503 }); + const errorWithStatus = Object.assign(new Error('test'), { status: 502 }); + const plainError = new Error('test'); + + expect(getStatusCode(errorWithStatusCode)).toBe(503); + expect(getStatusCode(errorWithStatus)).toBe(502); + expect(getStatusCode(plainError)).toBe(500); + }); + }); + + describe('isSentryConfigured and isTest guard logic', () => { + // Test the guard condition logic used throughout the module + + it('should block execution when Sentry is not configured', () => { + const isSentryConfigured = false; + const isTest = false; + + const shouldExecute = isSentryConfigured && !isTest; + expect(shouldExecute).toBe(false); + }); + + it('should block execution in test environment', () => { + const isSentryConfigured = true; + const isTest = true; + + const shouldExecute = isSentryConfigured && !isTest; + expect(shouldExecute).toBe(false); + }); + + it('should allow execution when configured and not in test', () => { + const isSentryConfigured = true; + const isTest = false; + + const shouldExecute = isSentryConfigured && !isTest; + expect(shouldExecute).toBe(true); + }); + }); + + describe('captureException with context', () => { + // Test the context-setting logic + + it('should set context when provided', () => { + const context = { userId: '123', action: 'test' }; + const shouldSetContext = !!context; + expect(shouldSetContext).toBe(true); + }); + + it('should not set context when not provided', () => { + const context = undefined; + const shouldSetContext = !!context; + expect(shouldSetContext).toBe(false); + }); + }); + + describe('captureMessage default level', () => { + it('should default to info level', () => { + // Test the default parameter behavior + const defaultLevel = 'info'; + expect(defaultLevel).toBe('info'); + }); + + it('should accept other severity levels', () => { + const validLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug']; + validLevels.forEach((level) => { + expect(['fatal', 'error', 'warning', 'log', 'info', 'debug']).toContain(level); + }); + }); + }); + + describe('setUser', () => { + it('should accept user object with id only', () => { + const user = { id: '123' }; + expect(user.id).toBe('123'); + expect(user).not.toHaveProperty('email'); + }); + + it('should accept user object with all fields', () => { + const user = { id: '123', email: 'test@example.com', username: 'testuser' }; + expect(user.id).toBe('123'); + expect(user.email).toBe('test@example.com'); + expect(user.username).toBe('testuser'); + }); + + it('should accept null to clear user', () => { + const user = null; + expect(user).toBeNull(); + }); + }); + + describe('addBreadcrumb', () => { + it('should accept breadcrumb with message', () => { + const breadcrumb = { message: 'User clicked button' }; + expect(breadcrumb.message).toBe('User clicked button'); + }); + + it('should accept breadcrumb with category', () => { + const breadcrumb = { message: 'Navigation', category: 'navigation' }; + expect(breadcrumb.category).toBe('navigation'); + }); + + it('should accept breadcrumb with level', () => { + const breadcrumb = { message: 'Error occurred', level: 'error' as const }; + expect(breadcrumb.level).toBe('error'); + }); + + it('should accept breadcrumb with data', () => { + const breadcrumb = { + message: 'API call', + category: 'http', + data: { url: '/api/test', method: 'GET' }, + }; + expect(breadcrumb.data).toEqual({ url: '/api/test', method: 'GET' }); + }); + }); +});