Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 34 additions & 26 deletions src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Partial<PrismaService>>;
let prisma: MockPrisma;

/** Collects data passed to createMany for assertions. */
let createManySink: any[];
Expand All @@ -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
Expand All @@ -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 () => {
Expand All @@ -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);
});
});
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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 });
});
Expand All @@ -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',
Expand All @@ -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);
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand All @@ -283,21 +291,21 @@ describe('AnalyticsService', () => {

await service.reset();

expect(prisma.requestLog!.deleteMany).toHaveBeenCalled();
expect(prisma.requestLog.deleteMany).toHaveBeenCalled();
});
});

// ── Retention cleanup ───────────────────────────────────────────────────

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) },
},
Expand All @@ -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,
});

Expand All @@ -335,7 +343,7 @@ describe('AnalyticsService', () => {

await service.onModuleDestroy();

expect(prisma.requestLog!.createMany).toHaveBeenCalled();
expect(prisma.requestLog.createMany).toHaveBeenCalled();
expect(clearInterval).toHaveBeenCalled();
});

Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);

Expand Down
14 changes: 12 additions & 2 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
6 changes: 3 additions & 3 deletions src/auth/login-rate-limit.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
7 changes: 3 additions & 4 deletions src/cache/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
keys.add(key);
this.cacheTagMap.set(tag, keys);
}

/**
Expand Down
8 changes: 5 additions & 3 deletions src/duplicate-detection/duplicate-detection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,15 @@ export class DuplicateDetectionService {

const propertyMatches = new Map<string, { property: any; matchedImages: string[] }>();
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());
Expand Down
23 changes: 17 additions & 6 deletions src/email-digest/email-digest.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<PrismaService>>;
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,
Expand All @@ -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');
});
});
});
5 changes: 4 additions & 1 deletion src/neighborhoods/neighborhoods.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,10 @@ export class NeighborhoodsService {
where: { id: neighborhoodId },
select: { metadata: true },
});
const metadata = (neighborhood!.metadata as Record<string, any>) || {};
if (!neighborhood) {
throw new NotFoundException(`Neighborhood ${neighborhoodId} not found`);
}
const metadata = (neighborhood.metadata as Record<string, any>) || {};
return Array.isArray(metadata.scoreHistory) ? metadata.scoreHistory : [];
}

Expand Down
7 changes: 3 additions & 4 deletions src/notifications/notifications.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
sockets.add(client.id);
this.userSockets.set(userId, sockets);
this.socketUsers.set(client.id, userId);

// Socket.IO room (for local delivery via .to())
Expand Down
14 changes: 10 additions & 4 deletions src/properties/properties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 7 additions & 1 deletion src/property-comparison/property-comparison.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading