Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
54 changes: 10 additions & 44 deletions src/email-digest/email-digest.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ 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 emailService: { sendEmail: jest.Mock };
let configService: { get: jest.Mock };
let prisma: MockPrisma;

beforeEach(() => {
prisma = {
Expand All @@ -27,17 +32,7 @@ describe('EmailDigestService', () => {
unsubscribeToken: 'tok',
}),
findUnique: jest.fn().mockResolvedValue(null),
} as any,
notification: {
findMany: jest.fn().mockResolvedValue([
{
title: 'New property update',
message: 'A property has new activity',
type: 'INFO',
createdAt: new Date('2026-08-31T12:00:00.000Z'),
},
]),
} as any,
},
};
emailService = { sendEmail: jest.fn().mockResolvedValue(undefined) };
configService = { get: jest.fn().mockReturnValue('https://api.propchain.example/api') };
Expand All @@ -51,38 +46,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');
});

it('uses configured API_URL for digest unsubscribe links', async () => {
await service['sendDigestForUser'](
{ id: 'u1', email: 'user@example.com', firstName: 'User' },
new Date('2026-08-30T12:00:00.000Z'),
'token-123',
);

expect(emailService.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
html: expect.stringContaining(
'https://api.propchain.example/api/email-digest/unsubscribe?token=token-123',
),
}),
);
});

it('fails when API_URL is missing for digest unsubscribe links', async () => {
configService.get.mockReturnValue(undefined);

await expect(
service['sendDigestForUser'](
{ id: 'u1', email: 'user@example.com', firstName: 'User' },
new Date('2026-08-30T12:00:00.000Z'),
'token-123',
),
).rejects.toThrow('API_URL environment variable is not set');
});
});
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
Loading
Loading