From 85a2b43683505e5342bc1de2a391c512162f475d Mon Sep 17 00:00:00 2001 From: shadrackmanfred Date: Tue, 1 Sep 2026 04:31:31 +0000 Subject: [PATCH] fix: replace non-null assertions with explicit handling Remove all non-null assertion operators in src/ so null paths surface typed errors or defaults instead of crashing with a generic TypeError. - cache/notifications/duplicate-detection: fall back to a fresh Set or existing map entry instead of asserting presence after a guarded get - properties: validate lat/lng upfront and use narrowed locals for the radius bounding-box math - auth: guard TOTP/backup codes before verifying 2FA - property-comparison/neighborhoods: throw NotFoundException when a looked-up entity is missing - versioning: only set the Sunset header when a date resolves - specs: type prisma mocks fully so requestLog!/supportTicket!/ digestPreference!/info! assertions are no longer needed --- src/analytics/analytics.service.spec.ts | 60 +++++++++++-------- src/auth/auth.service.ts | 14 ++++- src/auth/login-rate-limit.service.spec.ts | 6 +- src/cache/cache.service.ts | 7 +-- .../duplicate-detection.service.ts | 8 ++- src/email-digest/email-digest.service.spec.ts | 23 +++++-- src/neighborhoods/neighborhoods.service.ts | 5 +- src/notifications/notifications.gateway.ts | 7 +-- src/properties/properties.service.ts | 14 +++-- .../property-comparison.service.ts | 8 ++- .../support-tickets.service.spec.ts | 26 +++++--- .../deprecation-warning.interceptor.ts | 5 +- 12 files changed, 121 insertions(+), 62 deletions(-) diff --git a/src/analytics/analytics.service.spec.ts b/src/analytics/analytics.service.spec.ts index 765ffd25..bdd2ccfb 100644 --- a/src/analytics/analytics.service.spec.ts +++ b/src/analytics/analytics.service.spec.ts @@ -1,9 +1,17 @@ import { AnalyticsService } from './analytics.service'; import { PrismaService } from '../database/prisma.service'; +interface MockPrisma { + requestLog: { + createMany: jest.Mock; + findMany: jest.Mock; + deleteMany: jest.Mock; + }; +} + describe('AnalyticsService', () => { let service: AnalyticsService; - let prisma: jest.Mocked>; + let prisma: MockPrisma; /** Collects data passed to createMany for assertions. */ let createManySink: any[]; @@ -19,7 +27,7 @@ describe('AnalyticsService', () => { }), findMany: jest.fn().mockResolvedValue([]), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), - } as any, + }, }; // Stub setInterval so the periodic flush timer doesn't run in tests @@ -46,7 +54,7 @@ describe('AnalyticsService', () => { userId: null, }); - expect(prisma.requestLog!.createMany).not.toHaveBeenCalled(); + expect(prisma.requestLog.createMany).not.toHaveBeenCalled(); }); it('flushes to the database when buffer reaches MAX_BUFFER_SIZE', async () => { @@ -62,7 +70,7 @@ describe('AnalyticsService', () => { } // flush() should have been triggered - expect(prisma.requestLog!.createMany).toHaveBeenCalled(); + expect(prisma.requestLog.createMany).toHaveBeenCalled(); expect(createManySink).toHaveLength(500); }); }); @@ -86,7 +94,7 @@ describe('AnalyticsService', () => { await service.flush(); - expect(prisma.requestLog!.createMany).toHaveBeenCalledTimes(1); + expect(prisma.requestLog.createMany).toHaveBeenCalledTimes(1); expect(createManySink).toHaveLength(2); expect(createManySink[0]).toMatchObject({ endpoint: '/api/properties', @@ -106,11 +114,11 @@ describe('AnalyticsService', () => { it('is a no-op when the buffer is empty', async () => { await service.flush(); - expect(prisma.requestLog!.createMany).not.toHaveBeenCalled(); + expect(prisma.requestLog.createMany).not.toHaveBeenCalled(); }); it('re-prepends records on DB failure for retry', async () => { - (prisma.requestLog!.createMany as jest.Mock).mockRejectedValueOnce(new Error('DB error')); + prisma.requestLog.createMany.mockRejectedValueOnce(new Error('DB error')); service.record({ endpoint: '/api/test', @@ -126,8 +134,8 @@ describe('AnalyticsService', () => { expect((service as any).buffer).toHaveLength(1); // Reset mock to default and flush again - (prisma.requestLog!.createMany as jest.Mock).mockReset(); - (prisma.requestLog!.createMany as jest.Mock).mockImplementation((args: any) => { + prisma.requestLog.createMany.mockReset(); + prisma.requestLog.createMany.mockImplementation((args: any) => { createManySink.push(...args.data); return Promise.resolve({ count: args.data.length }); }); @@ -144,7 +152,7 @@ describe('AnalyticsService', () => { describe('restart persistence', () => { it('getStats reads from the database, not from memory', async () => { const now = new Date(); - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([ + prisma.requestLog.findMany.mockResolvedValue([ { endpoint: '/api/properties', method: 'GET', @@ -165,7 +173,7 @@ describe('AnalyticsService', () => { const stats = await service.getStats(60); - expect(prisma.requestLog!.findMany).toHaveBeenCalled(); + expect(prisma.requestLog.findMany).toHaveBeenCalled(); expect(stats.totalRequests).toBe(2); expect(stats.totalErrors).toBe(1); expect(stats.overallErrorRate).toBe(50); @@ -183,7 +191,7 @@ describe('AnalyticsService', () => { describe('getEndpointStats()', () => { it('returns endpoint breakdown from database records', async () => { - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([ + prisma.requestLog.findMany.mockResolvedValue([ { endpoint: '/api/properties', method: 'GET', @@ -225,7 +233,7 @@ describe('AnalyticsService', () => { describe('getUserStats()', () => { it('returns usage stats for a specific user', async () => { const now = new Date(); - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([ + prisma.requestLog.findMany.mockResolvedValue([ { endpoint: '/api/properties', method: 'GET', @@ -255,14 +263,14 @@ describe('AnalyticsService', () => { const stats = await service.getUserStats('user-1', 60); expect(stats).not.toBeNull(); - expect(stats!.userId).toBe('user-1'); - expect(stats!.requestCount).toBe(2); - expect(stats!.errorCount).toBe(1); - expect(stats!.avgResponseTime).toBe(300); + expect(stats?.userId).toBe('user-1'); + expect(stats?.requestCount).toBe(2); + expect(stats?.errorCount).toBe(1); + expect(stats?.avgResponseTime).toBe(300); }); it('returns null when no records exist for the user', async () => { - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([]); + prisma.requestLog.findMany.mockResolvedValue([]); const stats = await service.getUserStats('nonexistent', 60); expect(stats).toBeNull(); @@ -283,7 +291,7 @@ describe('AnalyticsService', () => { await service.reset(); - expect(prisma.requestLog!.deleteMany).toHaveBeenCalled(); + expect(prisma.requestLog.deleteMany).toHaveBeenCalled(); }); }); @@ -291,13 +299,13 @@ describe('AnalyticsService', () => { describe('pruneExpiredRecords()', () => { it('deletes records older than the retention period', async () => { - (prisma.requestLog!.deleteMany as jest.Mock).mockResolvedValue({ + prisma.requestLog.deleteMany.mockResolvedValue({ count: 42, }); await service.pruneExpiredRecords(); - expect(prisma.requestLog!.deleteMany).toHaveBeenCalledWith({ + expect(prisma.requestLog.deleteMany).toHaveBeenCalledWith({ where: { timestamp: { lt: expect.any(Date) }, }, @@ -309,7 +317,7 @@ describe('AnalyticsService', () => { // Replace logger.log for the duration of this test (service as any).logger = { log: logSpy, error: jest.fn() }; - (prisma.requestLog!.deleteMany as jest.Mock).mockResolvedValue({ + prisma.requestLog.deleteMany.mockResolvedValue({ count: 10, }); @@ -335,7 +343,7 @@ describe('AnalyticsService', () => { await service.onModuleDestroy(); - expect(prisma.requestLog!.createMany).toHaveBeenCalled(); + expect(prisma.requestLog.createMany).toHaveBeenCalled(); expect(clearInterval).toHaveBeenCalled(); }); @@ -360,7 +368,7 @@ describe('AnalyticsService', () => { describe('getStats() aggregation', () => { it('computes slow endpoints correctly', async () => { const now = new Date(); - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([ + prisma.requestLog.findMany.mockResolvedValue([ { endpoint: '/api/slow', method: 'GET', @@ -388,7 +396,7 @@ describe('AnalyticsService', () => { it('computes errorsByStatus correctly', async () => { const now = new Date(); - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue([ + prisma.requestLog.findMany.mockResolvedValue([ { endpoint: '/a', method: 'GET', @@ -450,7 +458,7 @@ describe('AnalyticsService', () => { timestamp: now, })); - (prisma.requestLog!.findMany as jest.Mock).mockResolvedValue(records); + prisma.requestLog.findMany.mockResolvedValue(records); const stats = await service.getStats(60); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index cb9fb532..80b9a2e7 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -382,16 +382,26 @@ export class AuthService { } if (hasTotpCode && user.twoFactorSecret) { + const totpCode = data.totpCode; + if (!totpCode) { + throw new UnauthorizedException('Two-factor authentication code required'); + } + const validCode = verifyTotpCode({ secret: user.twoFactorSecret, - code: data.totpCode!, + code: totpCode, }); if (!validCode) { throw new UnauthorizedException('Invalid two-factor authentication code'); } } else if (hasBackupCode) { - const matchingBackupCode = verifyBackupCode(data.backupCode!, user.twoFactorBackupCodes); + const backupCode = data.backupCode; + if (!backupCode) { + throw new UnauthorizedException('Two-factor authentication code required'); + } + + const matchingBackupCode = verifyBackupCode(backupCode, user.twoFactorBackupCodes); if (!matchingBackupCode) { throw new UnauthorizedException('Invalid backup code'); } diff --git a/src/auth/login-rate-limit.service.spec.ts b/src/auth/login-rate-limit.service.spec.ts index a708f97a..7f566624 100644 --- a/src/auth/login-rate-limit.service.spec.ts +++ b/src/auth/login-rate-limit.service.spec.ts @@ -103,9 +103,9 @@ describe('LoginRateLimitService', () => { .mockResolvedValueOnce({ unlockAt }); // getLockoutInfo detail const info = await service.getLockoutInfo(email); expect(info).not.toBeNull(); - expect(info!.isLocked).toBe(true); - expect(info!.failedAttempts).toBe(5); - expect(info!.remainingLockoutMinutes).toBeGreaterThan(0); + expect(info?.isLocked).toBe(true); + expect(info?.failedAttempts).toBe(5); + expect(info?.remainingLockoutMinutes).toBeGreaterThan(0); }); }); }); diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index aaace235..5883c2b2 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -142,10 +142,9 @@ export class CacheService { * Tag a cache key for grouped invalidation */ private tagKey(tag: string, key: string): void { - if (!this.cacheTagMap.has(tag)) { - this.cacheTagMap.set(tag, new Set()); - } - this.cacheTagMap.get(tag)!.add(key); + const keys = this.cacheTagMap.get(tag) ?? new Set(); + keys.add(key); + this.cacheTagMap.set(tag, keys); } /** diff --git a/src/duplicate-detection/duplicate-detection.service.ts b/src/duplicate-detection/duplicate-detection.service.ts index e63f7f1f..474de0de 100644 --- a/src/duplicate-detection/duplicate-detection.service.ts +++ b/src/duplicate-detection/duplicate-detection.service.ts @@ -515,13 +515,15 @@ export class DuplicateDetectionService { const propertyMatches = new Map(); for (const img of matchingImages) { - if (!propertyMatches.has(img.propertyId)) { + const existing = propertyMatches.get(img.propertyId); + if (existing) { + existing.matchedImages.push(img.id); + } else { propertyMatches.set(img.propertyId, { property: img.property, - matchedImages: [], + matchedImages: [img.id], }); } - propertyMatches.get(img.propertyId)!.matchedImages.push(img.id); } return Array.from(propertyMatches.values()); diff --git a/src/email-digest/email-digest.service.spec.ts b/src/email-digest/email-digest.service.spec.ts index 7134080e..d49342b3 100644 --- a/src/email-digest/email-digest.service.spec.ts +++ b/src/email-digest/email-digest.service.spec.ts @@ -3,16 +3,28 @@ import { PrismaService } from '../database/prisma.service'; import { EmailService } from '../email/email.service'; import { ConfigService } from '@nestjs/config'; +interface MockPrisma { + digestPreference: { + upsert: jest.Mock; + findUnique: jest.Mock; + }; +} + describe('EmailDigestService', () => { let service: EmailDigestService; - let prisma: jest.Mocked>; + let prisma: MockPrisma; beforeEach(() => { prisma = { digestPreference: { - upsert: jest.fn().mockResolvedValue({ userId: 'u1', enabled: true, frequency: 'DAILY', unsubscribeToken: 'tok' }), + upsert: jest.fn().mockResolvedValue({ + userId: 'u1', + enabled: true, + frequency: 'DAILY', + unsubscribeToken: 'tok', + }), findUnique: jest.fn().mockResolvedValue(null), - } as any, + }, }; service = new EmailDigestService( prisma as unknown as PrismaService, @@ -23,10 +35,9 @@ describe('EmailDigestService', () => { it('getOrCreatePreference creates preference for new user', async () => { const result = await service.getOrCreatePreference('u1'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(prisma.digestPreference!.upsert).toHaveBeenCalledWith( + expect(prisma.digestPreference.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'u1' } }), ); expect(result.userId).toBe('u1'); }); -}); \ No newline at end of file +}); diff --git a/src/neighborhoods/neighborhoods.service.ts b/src/neighborhoods/neighborhoods.service.ts index 8b3b05da..6845b39f 100644 --- a/src/neighborhoods/neighborhoods.service.ts +++ b/src/neighborhoods/neighborhoods.service.ts @@ -284,7 +284,10 @@ export class NeighborhoodsService { where: { id: neighborhoodId }, select: { metadata: true }, }); - const metadata = (neighborhood!.metadata as Record) || {}; + if (!neighborhood) { + throw new NotFoundException(`Neighborhood ${neighborhoodId} not found`); + } + const metadata = (neighborhood.metadata as Record) || {}; return Array.isArray(metadata.scoreHistory) ? metadata.scoreHistory : []; } diff --git a/src/notifications/notifications.gateway.ts b/src/notifications/notifications.gateway.ts index b8849441..4073d6a7 100644 --- a/src/notifications/notifications.gateway.ts +++ b/src/notifications/notifications.gateway.ts @@ -127,10 +127,9 @@ export class NotificationsGateway } // Local tracking (fast-path cache) - if (!this.userSockets.has(userId)) { - this.userSockets.set(userId, new Set()); - } - this.userSockets.get(userId)!.add(client.id); + const sockets = this.userSockets.get(userId) ?? new Set(); + sockets.add(client.id); + this.userSockets.set(userId, sockets); this.socketUsers.set(client.id, userId); // Socket.IO room (for local delivery via .to()) diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index 2d4f7697..6aa636d2 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -751,19 +751,25 @@ export class PropertiesService { throw new BadRequestException('Both lat and lng must be provided together'); } + const lat = dto.lat; + const lng = dto.lng; + if (lat === undefined || lng === undefined) { + throw new BadRequestException('Both lat and lng must be provided together'); + } + const hasBoundingBox = dto.minLat !== undefined || dto.maxLat !== undefined || dto.minLng !== undefined || dto.maxLng !== undefined; - if (hasLat && hasLng) { + if (lat !== undefined && lng !== undefined) { if (dto.radiusKm !== undefined) { // Approximate radius as bounding box (1 degree lat ≈ 111km) const latDelta = dto.radiusKm / 111; - const lngDelta = dto.radiusKm / (111 * Math.cos((dto.lat! * Math.PI) / 180)); - where.latitude = { gte: dto.lat! - latDelta, lte: dto.lat! + latDelta }; - where.longitude = { gte: dto.lng! - lngDelta, lte: dto.lng! + lngDelta }; + const lngDelta = dto.radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + where.latitude = { gte: lat - latDelta, lte: lat + latDelta }; + where.longitude = { gte: lng - lngDelta, lte: lng + lngDelta }; } else if (!hasBoundingBox) { throw new BadRequestException( 'When providing lat/lng, either radiusKm or bounding box params (minLat/maxLat/minLng/maxLng) must also be provided', diff --git a/src/property-comparison/property-comparison.service.ts b/src/property-comparison/property-comparison.service.ts index aaf77e8a..0e82f7f5 100644 --- a/src/property-comparison/property-comparison.service.ts +++ b/src/property-comparison/property-comparison.service.ts @@ -78,7 +78,13 @@ export class PropertyComparisonService { throw new NotFoundException(`Properties not found: ${missing.join(', ')}`); } - const ordered = ids.map((id) => properties.find((p) => p.id === id)!); + const ordered = ids.map((id) => { + const property = properties.find((p) => p.id === id); + if (!property) { + throw new NotFoundException(`Property ${id} not found`); + } + return property; + }); const comparison: FieldRow[] = COMPARABLE_FIELDS.map((field) => this.buildFieldRow(field, ordered), diff --git a/src/support-tickets/support-tickets.service.spec.ts b/src/support-tickets/support-tickets.service.spec.ts index f6bdda15..f13517ae 100644 --- a/src/support-tickets/support-tickets.service.spec.ts +++ b/src/support-tickets/support-tickets.service.spec.ts @@ -2,19 +2,30 @@ import { SupportTicketsService } from './support-tickets.service'; import { PrismaService } from '../database/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +interface MockPrisma { + supportTicket: { + create: jest.Mock; + findUnique: jest.Mock; + findMany: jest.Mock; + update: jest.Mock; + }; +} + describe('SupportTicketsService', () => { let service: SupportTicketsService; - let prisma: jest.Mocked>; + let prisma: MockPrisma; let notifications: jest.Mocked>; beforeEach(() => { prisma = { supportTicket: { - create: jest.fn().mockResolvedValue({ id: 'ticket-1', priority: 'HIGH', slaDeadline: new Date() }), + create: jest + .fn() + .mockResolvedValue({ id: 'ticket-1', priority: 'HIGH', slaDeadline: new Date() }), findUnique: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue({}), - } as any, + }, }; notifications = { sendNotification: jest.fn().mockResolvedValue(undefined) }; service = new SupportTicketsService( @@ -25,10 +36,11 @@ describe('SupportTicketsService', () => { it('creates a ticket with correct SLA deadline for HIGH priority', async () => { const result = await service.createTicket('user-1', { - subject: 'Test', description: 'Desc', priority: 'HIGH', + subject: 'Test', + description: 'Desc', + priority: 'HIGH', } as any); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(prisma.supportTicket!.create).toHaveBeenCalled(); + expect(prisma.supportTicket.create).toHaveBeenCalled(); expect(result.id).toBe('ticket-1'); }); -}); \ No newline at end of file +}); diff --git a/src/versioning/deprecation-warning.interceptor.ts b/src/versioning/deprecation-warning.interceptor.ts index 220a9387..e97af64c 100644 --- a/src/versioning/deprecation-warning.interceptor.ts +++ b/src/versioning/deprecation-warning.interceptor.ts @@ -101,7 +101,10 @@ export class DeprecationWarningInterceptor implements NestInterceptor { response.setHeader('Deprecation', 'true'); if (meta?.sunsetDate) { - response.setHeader('Sunset', toHttpDate(meta.sunsetDate)!); + const sunset = toHttpDate(meta.sunsetDate); + if (sunset) { + response.setHeader('Sunset', sunset); + } const daysLeft = getSunsetCountdown(meta); if (daysLeft !== null) { response.setHeader('X-Sunset-Days-Remaining', String(daysLeft));