diff --git a/src/app.controller.ts b/src/app.controller.ts index 7cfe202c..d471b471 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -19,7 +19,9 @@ export class AppController { @Get() @ApiVersion([ApiVersionEnum.V1, ApiVersionEnum.V2]) - getHello(): string { return 'Welcome to PropChain API'; } + getHello(): string { + return 'Welcome to PropChain API'; + } @Get('health') @ApiVersion([ApiVersionEnum.V1, ApiVersionEnum.V2]) @@ -67,7 +69,11 @@ export class AppController { } const allOk = Object.values(checks).every((c: any) => c.status === 'ok'); - return { status: allOk ? 'OK' : 'DEGRADED', timestamp: new Date().toISOString(), services: checks }; + return { + status: allOk ? 'OK' : 'DEGRADED', + timestamp: new Date().toISOString(), + services: checks, + }; } @Get('health') diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 86f8fcda..eb1ae0fd 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -124,10 +124,14 @@ export class AuthService { const passwordHash = await hashPassword(data.password, this.bcryptRounds); const verificationToken = randomToken(32); - const verificationExpiresAt = new Date(Date.now() + parseDuration( - this.configService.get('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h', - 24 * 60 * 60, - ) * 1000); + const verificationExpiresAt = new Date( + Date.now() + + parseDuration( + this.configService.get('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h', + 24 * 60 * 60, + ) * + 1000, + ); const user = await this.prisma.user.create({ data: { @@ -168,13 +172,17 @@ export class AuthService { /** * Performs mandatory security checks before validating credentials. - * + * * Ordering Contract: * 1. Lockout check: Prevent any further action if account is temporarily locked. * 2. CAPTCHA check: If failed attempts exceed threshold, require CAPTCHA to proceed. * 3. Credentials check: (Performed in the main login method after preflight) */ - private async preflightChecks(data: LoginDto, ipAddress?: string, userAgent?: string): Promise { + private async preflightChecks( + data: LoginDto, + ipAddress?: string, + userAgent?: string, + ): Promise { // Check if account is locked out const isLocked = await this.rateLimitService.isAccountLocked(data.email); if (isLocked) { @@ -1245,6 +1253,10 @@ export class AuthService { }); } + /** + * Generate a new API key value with 'pc_' prefix and 24 random characters. + * Format: pc_<24-char-random-hex> + */ private generateApiKeyValue() { return `pc_${randomToken(24)}`; } diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts index a591e1ff..5fc996a4 100644 --- a/src/auth/guards/rate-limit.guard.ts +++ b/src/auth/guards/rate-limit.guard.ts @@ -52,6 +52,9 @@ export class RateLimitGuard implements CanActivate { const endpoint = `${request.method} ${request.route?.path || request.url}`; try { + // Check by user if authenticated + // Tier defaults to 'free' as it is not included in the current JWT payload. + // When 'tier' is added to JwtPayload, this logic will use the actual value. const ip = this.getClientIp(request); if (request.user?.id) { diff --git a/src/common/common.types.ts b/src/common/common.types.ts index 65fba431..55f40081 100644 --- a/src/common/common.types.ts +++ b/src/common/common.types.ts @@ -8,6 +8,11 @@ import { TransactionStatus, DocumentType, VerificationStatus, + FraudSeverity, + FraudStatus, + FraudPattern, + DisputeStatus, + MilestoneStatus, } from '@prisma/client'; registerEnumType(UserRole, { name: 'UserRole' }); @@ -16,6 +21,11 @@ registerEnumType(TransactionType, { name: 'TransactionType' }); registerEnumType(TransactionStatus, { name: 'TransactionStatus' }); registerEnumType(DocumentType, { name: 'DocumentType' }); registerEnumType(VerificationStatus, { name: 'VerificationStatus' }); +registerEnumType(FraudSeverity, { name: 'FraudSeverity' }); +registerEnumType(FraudStatus, { name: 'FraudStatus' }); +registerEnumType(FraudPattern, { name: 'FraudPattern' }); +registerEnumType(DisputeStatus, { name: 'DisputeStatus' }); +registerEnumType(MilestoneStatus, { name: 'MilestoneStatus' }); export { UserRole, @@ -24,4 +34,9 @@ export { TransactionStatus, DocumentType, VerificationStatus, + FraudSeverity, + FraudStatus, + FraudPattern, + DisputeStatus, + MilestoneStatus, }; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 4bf95eb0..47ed8f6d 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -41,9 +41,21 @@ export class NotificationsService { const message = `Your transaction for property "${transaction.property.title}" has been updated to ${transaction.status}.`; const [canInApp, canEmail, canSms] = await Promise.all([ - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'inApp'), - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'email'), - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'sms'), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'inApp', + ), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'email', + ), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'sms', + ), ]); await Promise.all([ @@ -73,9 +85,7 @@ export class NotificationsService { transaction.status === 'CANCELLED' ? new Date().toLocaleDateString() : undefined, }) : Promise.resolve(), - canSms && user.phone - ? this.smsService.sendSms(user.phone, message) - : Promise.resolve(), + canSms && user.phone ? this.smsService.sendSms(user.phone, message) : Promise.resolve(), ]); }), ); diff --git a/src/transactions/dto/transaction.dto.ts b/src/transactions/dto/transaction.dto.ts index 3a9f8f4b..c167229a 100644 --- a/src/transactions/dto/transaction.dto.ts +++ b/src/transactions/dto/transaction.dto.ts @@ -1,6 +1,16 @@ // @ts-nocheck -import { IsString, IsNumber, IsOptional, IsEnum, IsUUID, IsDate, IsIn, Min, Max } from 'class-validator'; +import { + IsString, + IsNumber, + IsOptional, + IsEnum, + IsUUID, + IsDate, + IsIn, + Min, + Max, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -189,13 +199,19 @@ export enum TransactionAnalyticsGranularity { } export class TransactionAnalyticsQueryDto { - @ApiPropertyOptional({ description: 'Only include transactions created on or after this date. Maximum date window is 365 days when both startDate and endDate are provided.' }) + @ApiPropertyOptional({ + description: + 'Only include transactions created on or after this date. Maximum date window is 365 days when both startDate and endDate are provided.', + }) @IsOptional() @Type(() => Date) @IsDate() startDate?: Date; - @ApiPropertyOptional({ description: 'Only include transactions created on or before this date. Maximum date window is 365 days when both startDate and endDate are provided.' }) + @ApiPropertyOptional({ + description: + 'Only include transactions created on or before this date. Maximum date window is 365 days when both startDate and endDate are provided.', + }) @IsOptional() @Type(() => Date) @IsDate() diff --git a/src/transactions/transactions.service.spec.ts b/src/transactions/transactions.service.spec.ts index 21cdf0d2..f770d820 100644 --- a/src/transactions/transactions.service.spec.ts +++ b/src/transactions/transactions.service.spec.ts @@ -259,28 +259,14 @@ describe('TransactionsService', () => { it('should cap date ranges larger than maxDays', async () => { const startDate = new Date('2025-01-01T00:00:00.000Z'); const endDate = new Date('2026-01-02T00:00:00.000Z'); - const cappedEnd = new Date(startDate); - cappedEnd.setDate(cappedEnd.getDate() + 365); - - mockPrismaService.transaction.findMany.mockResolvedValue([]); - - const result = await service.getAnalytics({ - startDate, - endDate, - granularity: TransactionAnalyticsGranularity.MONTH, - }); - - expect(prisma.transaction.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - createdAt: expect.objectContaining({ - gte: startDate, - lte: cappedEnd, - }), - }), + await expect( + service.getAnalytics({ + startDate, + endDate, + granularity: TransactionAnalyticsGranularity.MONTH, }), - ); - expect(result.totalTransactions).toBe(0); + ).rejects.toThrow(BadRequestException); + expect(prisma.transaction.findMany).not.toHaveBeenCalled(); }); }); }); diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 0981df69..ab7041dd 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -321,6 +321,11 @@ export class TransactionsService { if (query.endDate.getTime() < query.startDate.getTime()) { throw new BadRequestException('endDate must be on or after startDate'); } + const maxRangeMs = (query.maxDays ?? 365) * 24 * 60 * 60 * 1000; + const durationMs = query.endDate.getTime() - query.startDate.getTime(); + if (durationMs > maxRangeMs) { + throw new BadRequestException(`Date range cannot exceed ${query.maxDays ?? 365} days`); + } } if (query.type) { @@ -332,15 +337,7 @@ export class TransactionsService { if (query.startDate) where.createdAt.gte = query.startDate; if (query.endDate) where.createdAt.lte = query.endDate; - if (query.startDate && query.endDate) { - const diffMs = new Date(query.endDate).getTime() - new Date(query.startDate).getTime(); - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays > maxDays) { - const cappedEnd = new Date(query.startDate); - cappedEnd.setDate(cappedEnd.getDate() + maxDays); - where.createdAt.lte = cappedEnd; - } - } else if (query.startDate && !query.endDate) { + if (query.startDate && !query.endDate) { const cappedEnd = new Date(query.startDate); cappedEnd.setDate(cappedEnd.getDate() + maxDays); where.createdAt.lte = cappedEnd; @@ -534,7 +531,9 @@ export class TransactionsService { }, }) .then((result: any) => { - this.logger.log(`Tax strategy created for transaction ${transactionId}: ${dto.strategyType}`); + this.logger.log( + `Tax strategy created for transaction ${transactionId}: ${dto.strategyType}`, + ); this.notificationsService.sendNotification( user.sub, 'Tax Strategy Created', diff --git a/src/users/pipes/filename-validation.pipe.ts b/src/users/pipes/filename-validation.pipe.ts index 659fab1f..60f6145b 100644 --- a/src/users/pipes/filename-validation.pipe.ts +++ b/src/users/pipes/filename-validation.pipe.ts @@ -11,9 +11,7 @@ export class FilenameValidationPipe implements PipeTransform { } if (value.length > MAX_FILENAME_LENGTH) { - throw new BadRequestException( - `Filename must not exceed ${MAX_FILENAME_LENGTH} characters`, - ); + throw new BadRequestException(`Filename must not exceed ${MAX_FILENAME_LENGTH} characters`); } if (value.includes('..') || value.includes('/') || value.includes('\\')) { diff --git a/src/utils/validate-env.ts b/src/utils/validate-env.ts index 5b1f8668..768bd281 100644 --- a/src/utils/validate-env.ts +++ b/src/utils/validate-env.ts @@ -1,8 +1,4 @@ -const REQUIRED_ENV_VARS = [ - 'DATABASE_URL', - 'JWT_SECRET', - 'JWT_REFRESH_SECRET', -] as const; +const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_SECRET', 'JWT_REFRESH_SECRET'] as const; export function validateEnvironment(): void { const MISSING: string[] = []; diff --git a/test/e2e/analytics-date-range.spec.ts b/test/e2e/analytics-date-range.spec.ts index 6b1a3bd8..48a3f2b8 100644 --- a/test/e2e/analytics-date-range.spec.ts +++ b/test/e2e/analytics-date-range.spec.ts @@ -18,7 +18,18 @@ describe('Analytics date range boundary (e2e)', () => { const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ TransactionsService, - { provide: PrismaService, useValue: { transaction: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0) }, $connect: jest.fn(), $disconnect: jest.fn(), $transaction: jest.fn((a: any) => Promise.all(a)) } }, + { + provide: PrismaService, + useValue: { + transaction: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + $connect: jest.fn(), + $disconnect: jest.fn(), + $transaction: jest.fn((a: any) => Promise.all(a)), + }, + }, { provide: BlockchainService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: CommissionsService, useValue: {} }, @@ -31,12 +42,12 @@ describe('Analytics date range boundary (e2e)', () => { service = moduleRef.get(TransactionsService); }); - it('should cap date range at maxDays when startDate and endDate exceed limit', async () => { + it('should reject date ranges exceeding maxDays', async () => { const startDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); const endDate = new Date(); - const result = await service.getAnalytics({ startDate, endDate, maxDays: 365 }); - expect(result).toBeDefined(); - expect(result.totalTransactions).toBe(0); + await expect(service.getAnalytics({ startDate, endDate, maxDays: 365 })).rejects.toThrow( + BadRequestException, + ); }); it('should cap date range at 365 days when only startDate is provided', async () => { diff --git a/test/e2e/documents.e2e.spec.ts b/test/e2e/documents.e2e.spec.ts index e7ab6057..e2daf35a 100644 --- a/test/e2e/documents.e2e.spec.ts +++ b/test/e2e/documents.e2e.spec.ts @@ -1,4 +1,10 @@ -import { INestApplication, ValidationPipe, Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + Injectable, + CanActivate, + ExecutionContext, +} from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { PrismaService } from '../../src/database/prisma.service'; @@ -12,7 +18,12 @@ import { AuthUserPayload } from '../../src/auth/types/auth-user.type'; class MockAuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - request.authUser = { sub: 'test-user-id', email: 'test@example.com', role: 'USER', type: 'access' } as AuthUserPayload; + request.authUser = { + sub: 'test-user-id', + email: 'test@example.com', + role: 'USER', + type: 'access', + } as AuthUserPayload; return true; } } @@ -22,12 +33,27 @@ class FakePrismaService { async $connect() {} async $disconnect() {} - async $transaction(arr: any[]) { return Promise.all(arr); } + async $transaction(arr: any[]) { + return Promise.all(arr); + } document = { create: async ({ data }: any) => { const id = data.id ?? Math.random().toString(36).slice(2, 10); - const record = { id, ...data, tags: data.tags ?? [], sharedWith: data.sharedWith ?? [], isPublic: false, isExpired: false, expiryNotified: false, status: 'ACTIVE', auditTrail: [], userId: data.userId ?? 'test-user-id', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + const record = { + id, + ...data, + tags: data.tags ?? [], + sharedWith: data.sharedWith ?? [], + isPublic: false, + isExpired: false, + expiryNotified: false, + status: 'ACTIVE', + auditTrail: [], + userId: data.userId ?? 'test-user-id', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; this.documents.set(id, record); return record; }, @@ -38,9 +64,11 @@ class FakePrismaService { items = items.filter((d) => { for (const k of Object.keys(where)) { if (k === 'OR') { - if (!where.OR.some((c: any) => Object.entries(c).every(([ck, cv]) => d[ck] === cv))) return false; - } else if (k === 'status') { if (d.status !== where[k]) return false; } - else if (d[k] !== where[k]) return false; + if (!where.OR.some((c: any) => Object.entries(c).every(([ck, cv]) => d[ck] === cv))) + return false; + } else if (k === 'status') { + if (d.status !== where[k]) return false; + } else if (d[k] !== where[k]) return false; } return true; }); @@ -58,8 +86,26 @@ class FakePrismaService { this.documents.delete(where.id); return doc; }, - updateMany: async ({ where, data }: any) => { let c = 0; for (const [id, d] of this.documents) { if (where?.status && d.status === where.status) { this.documents.set(id, { ...d, ...data }); c++; } } return { count: c }; }, - deleteMany: async ({ where }: any) => { let c = 0; for (const [id, d] of this.documents) { if (where?.isExpired && d.isExpired) { this.documents.delete(id); c++; } } return { count: c }; }, + updateMany: async ({ where, data }: any) => { + let c = 0; + for (const [id, d] of this.documents) { + if (where?.status && d.status === where.status) { + this.documents.set(id, { ...d, ...data }); + c++; + } + } + return { count: c }; + }, + deleteMany: async ({ where }: any) => { + let c = 0; + for (const [id, d] of this.documents) { + if (where?.isExpired && d.isExpired) { + this.documents.delete(id); + c++; + } + } + return { count: c }; + }, } as any; } @@ -75,8 +121,24 @@ describe('Documents e2e', () => { DocumentsService, MockAuthGuard, { provide: PrismaService, useValue: fakePrisma as any }, - { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, - { provide: SignedUrlService, useValue: { isConfigured: () => false, getSignedUrl: async () => ({ url: '', objectKey: '', expiresAt: new Date() }) } as any }, + { + provide: AuthService, + useValue: { + validateAccessToken: async () => ({ + sub: 'test-user-id', + email: 'test@example.com', + role: 'USER' as any, + type: 'access', + }), + } as any, + }, + { + provide: SignedUrlService, + useValue: { + isConfigured: () => false, + getSignedUrl: async () => ({ url: '', objectKey: '', expiresAt: new Date() }), + } as any, + }, ], }).compile(); @@ -93,34 +155,88 @@ describe('Documents e2e', () => { const res = await request(app.getHttpServer()) .post('/documents') .set('Authorization', 'Bearer test') - .send({ documentType: 'CONTRACT', fileName: 'contract.pdf', fileUrl: 'https://example.com/contract.pdf', fileSize: 1024, mimeType: 'application/pdf' }) + .send({ + documentType: 'CONTRACT', + fileName: 'contract.pdf', + fileUrl: 'https://example.com/contract.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.documentType).toBe('CONTRACT'); }); it('lists documents', async () => { - await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'TITLE_DEED', fileName: 'deed.pdf', fileUrl: 'https://example.com/deed.pdf' }).expect(201); - const res = await request(app.getHttpServer()).get('/documents').set('Authorization', 'Bearer test').expect(200); + await request(app.getHttpServer()) + .post('/documents') + .set('Authorization', 'Bearer test') + .send({ + documentType: 'TITLE_DEED', + fileName: 'deed.pdf', + fileUrl: 'https://example.com/deed.pdf', + }) + .expect(201); + const res = await request(app.getHttpServer()) + .get('/documents') + .set('Authorization', 'Bearer test') + .expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBeGreaterThanOrEqual(1); }); it('finds a document by id', async () => { - const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'APPRAISAL', fileName: 'appraisal.pdf', fileUrl: 'https://example.com/appraisal.pdf' }).expect(201); - const res = await request(app.getHttpServer()).get(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(200); + const created = await request(app.getHttpServer()) + .post('/documents') + .set('Authorization', 'Bearer test') + .send({ + documentType: 'APPRAISAL', + fileName: 'appraisal.pdf', + fileUrl: 'https://example.com/appraisal.pdf', + }) + .expect(201); + const res = await request(app.getHttpServer()) + .get(`/documents/${created.body.id}`) + .set('Authorization', 'Bearer test') + .expect(200); expect(res.body.id).toBe(created.body.id); }); it('updates a document', async () => { - const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'DISCLOSURE', fileName: 'disc.pdf', fileUrl: 'https://example.com/disc.pdf' }).expect(201); - const res = await request(app.getHttpServer()).put(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').send({ description: 'Updated' }).expect(200); + const created = await request(app.getHttpServer()) + .post('/documents') + .set('Authorization', 'Bearer test') + .send({ + documentType: 'DISCLOSURE', + fileName: 'disc.pdf', + fileUrl: 'https://example.com/disc.pdf', + }) + .expect(201); + const res = await request(app.getHttpServer()) + .put(`/documents/${created.body.id}`) + .set('Authorization', 'Bearer test') + .send({ description: 'Updated' }) + .expect(200); expect(res.body.description).toBe('Updated'); }); it('deletes a document', async () => { - const created = await request(app.getHttpServer()).post('/documents').set('Authorization', 'Bearer test').send({ documentType: 'PHOTO', fileName: 'photo.jpg', fileUrl: 'https://example.com/photo.jpg' }).expect(201); - await request(app.getHttpServer()).delete(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(200); - await request(app.getHttpServer()).get(`/documents/${created.body.id}`).set('Authorization', 'Bearer test').expect(404); + const created = await request(app.getHttpServer()) + .post('/documents') + .set('Authorization', 'Bearer test') + .send({ + documentType: 'PHOTO', + fileName: 'photo.jpg', + fileUrl: 'https://example.com/photo.jpg', + }) + .expect(201); + await request(app.getHttpServer()) + .delete(`/documents/${created.body.id}`) + .set('Authorization', 'Bearer test') + .expect(200); + await request(app.getHttpServer()) + .get(`/documents/${created.body.id}`) + .set('Authorization', 'Bearer test') + .expect(404); }); }); diff --git a/test/e2e/favorites.e2e.spec.ts b/test/e2e/favorites.e2e.spec.ts index 8fa14b8b..ec072188 100644 --- a/test/e2e/favorites.e2e.spec.ts +++ b/test/e2e/favorites.e2e.spec.ts @@ -14,12 +14,20 @@ class FakePrismaService { async $connect() {} async $disconnect() {} - async $transaction(arr: any[]) { return Promise.all(arr); } + async $transaction(arr: any[]) { + return Promise.all(arr); + } property = { create: async ({ data }: any) => { const id = Math.random().toString(36).slice(2, 10); - const record = { id, ...data, ownerId: data.owner?.connect?.id ?? data.ownerId ?? null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + const record = { + id, + ...data, + ownerId: data.owner?.connect?.id ?? data.ownerId ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; if (record.price?.toString) record.price = Number(record.price.toString()); this.properties.set(id, record); return record; @@ -29,7 +37,9 @@ class FakePrismaService { propertyFavorite = { create: async ({ data }: any) => { - const existing = Array.from(this.propertyFavorites.values()).find((f) => f.userId === data.userId && f.propertyId === data.propertyId); + const existing = Array.from(this.propertyFavorites.values()).find( + (f) => f.userId === data.userId && f.propertyId === data.propertyId, + ); if (existing) throw Object.assign(new Error('Unique constraint'), { code: 'P2002' }); const id = Math.random().toString(36).slice(2, 10); const record = { id, ...data, createdAt: new Date().toISOString() }; @@ -38,20 +48,42 @@ class FakePrismaService { }, findUnique: async ({ where }: any) => { if (where?.id) return this.propertyFavorites.get(where.id) ?? null; - if (where?.userId_propertyId) return Array.from(this.propertyFavorites.values()).find((f) => f.userId === where.userId_propertyId.userId && f.propertyId === where.userId_propertyId.propertyId) ?? null; + if (where?.userId_propertyId) + return ( + Array.from(this.propertyFavorites.values()).find( + (f) => + f.userId === where.userId_propertyId.userId && + f.propertyId === where.userId_propertyId.propertyId, + ) ?? null + ); return null; }, findMany: async ({ where, skip = 0, take = 100 }: any) => { - let items = Array.from(this.propertyFavorites.values()).filter((f) => { if (!where) return true; for (const k of Object.keys(where)) { if (f[k] !== where[k]) return false; } return true; }); + const items = Array.from(this.propertyFavorites.values()).filter((f) => { + if (!where) return true; + for (const k of Object.keys(where)) { + if (f[k] !== where[k]) return false; + } + return true; + }); return items.slice(skip, skip + take); }, count: async ({ where }: any) => { - return Array.from(this.propertyFavorites.values()).filter((f) => { if (!where) return true; for (const k of Object.keys(where)) { if (f[k] !== where[k]) return false; } return true; }).length; + return Array.from(this.propertyFavorites.values()).filter((f) => { + if (!where) return true; + for (const k of Object.keys(where)) { + if (f[k] !== where[k]) return false; + } + return true; + }).length; }, deleteMany: async ({ where }: any) => { let count = 0; for (const [id, f] of this.propertyFavorites) { - if (f.userId === where.userId && f.propertyId === where.propertyId) { this.propertyFavorites.delete(id); count++; } + if (f.userId === where.userId && f.propertyId === where.propertyId) { + this.propertyFavorites.delete(id); + count++; + } } return { count }; }, @@ -70,7 +102,17 @@ describe('Favorites e2e', () => { providers: [ FavoritesService, { provide: PrismaService, useValue: fakePrisma as any }, - { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, + { + provide: AuthService, + useValue: { + validateAccessToken: async () => ({ + sub: 'test-user-id', + email: 'test@example.com', + role: 'USER' as any, + type: 'access', + }), + } as any, + }, ], }).compile(); @@ -87,7 +129,19 @@ describe('Favorites e2e', () => { beforeEach(() => { const id = crypto.randomUUID(); - fakePrisma.properties.set(id, { id, title: 'Fav Property', address: '456 Fav St', city: 'FavCity', state: 'FS', zipCode: '67890', country: 'US', price: 300000, ownerId: 'test-user-id', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); + fakePrisma.properties.set(id, { + id, + title: 'Fav Property', + address: '456 Fav St', + city: 'FavCity', + state: 'FS', + zipCode: '67890', + country: 'US', + price: 300000, + ownerId: 'test-user-id', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); propertyId = id; }); @@ -101,27 +155,51 @@ describe('Favorites e2e', () => { }); it('checks favorite status', async () => { - await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); - const res = await request(app.getHttpServer()).get(`/favorites/${propertyId}/status`).set('Authorization', 'Bearer test').expect(200); + await request(app.getHttpServer()) + .post(`/favorites/${propertyId}`) + .set('Authorization', 'Bearer test') + .expect(201); + const res = await request(app.getHttpServer()) + .get(`/favorites/${propertyId}/status`) + .set('Authorization', 'Bearer test') + .expect(200); expect(res.body.isFavorite).toBe(true); }); it('lists favorites', async () => { - await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); - const res = await request(app.getHttpServer()).get('/favorites').set('Authorization', 'Bearer test').expect(200); + await request(app.getHttpServer()) + .post(`/favorites/${propertyId}`) + .set('Authorization', 'Bearer test') + .expect(201); + const res = await request(app.getHttpServer()) + .get('/favorites') + .set('Authorization', 'Bearer test') + .expect(200); expect(res.body.items).toBeInstanceOf(Array); expect(res.body.items.length).toBeGreaterThanOrEqual(1); expect(res.body.total).toBeGreaterThanOrEqual(1); }); it('removes a favorite', async () => { - await request(app.getHttpServer()).post(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(201); - await request(app.getHttpServer()).delete(`/favorites/${propertyId}`).set('Authorization', 'Bearer test').expect(200); - const res = await request(app.getHttpServer()).get(`/favorites/${propertyId}/status`).set('Authorization', 'Bearer test').expect(200); + await request(app.getHttpServer()) + .post(`/favorites/${propertyId}`) + .set('Authorization', 'Bearer test') + .expect(201); + await request(app.getHttpServer()) + .delete(`/favorites/${propertyId}`) + .set('Authorization', 'Bearer test') + .expect(200); + const res = await request(app.getHttpServer()) + .get(`/favorites/${propertyId}/status`) + .set('Authorization', 'Bearer test') + .expect(200); expect(res.body.isFavorite).toBe(false); }); it('returns 404 for non-existent favorite removal', async () => { - await request(app.getHttpServer()).delete(`/favorites/${crypto.randomUUID()}`).set('Authorization', 'Bearer test').expect(404); + await request(app.getHttpServer()) + .delete(`/favorites/${crypto.randomUUID()}`) + .set('Authorization', 'Bearer test') + .expect(404); }); }); diff --git a/test/e2e/users-profile.e2e.spec.ts b/test/e2e/users-profile.e2e.spec.ts index 03642ce3..68845bda 100644 --- a/test/e2e/users-profile.e2e.spec.ts +++ b/test/e2e/users-profile.e2e.spec.ts @@ -13,7 +13,9 @@ class FakePrismaService { async $connect() {} async $disconnect() {} - async $transaction(arr: any[]) { return Promise.all(arr); } + async $transaction(arr: any[]) { + return Promise.all(arr); + } activityLog = { create: async (args: any) => args.data, @@ -29,14 +31,32 @@ class FakePrismaService { create: async ({ data }: any) => { const id = data.id ?? Math.random().toString(36).slice(2, 10); const record = { - id, ...data, role: data.role ?? 'USER', - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - lastActivityAt: null, isVerified: false, isBlocked: false, isDeactivated: false, - deactivatedAt: null, scheduledDeletionAt: null, twoFactorEnabled: false, - twoFactorSecret: null, twoFactorBackupCodes: [], avatar: null, pendingEmail: null, - emailVerificationToken: null, emailVerificationExpires: null, trustScore: 0, - lastTrustScoreUpdate: null, preferredChannel: null, languagePreference: null, - timezone: null, contactHours: null, referralCode: null, referredById: null, + id, + ...data, + role: data.role ?? 'USER', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastActivityAt: null, + isVerified: false, + isBlocked: false, + isDeactivated: false, + deactivatedAt: null, + scheduledDeletionAt: null, + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: [], + avatar: null, + pendingEmail: null, + emailVerificationToken: null, + emailVerificationExpires: null, + trustScore: 0, + lastTrustScoreUpdate: null, + preferredChannel: null, + languagePreference: null, + timezone: null, + contactHours: null, + referralCode: null, + referredById: null, }; this.users.set(id, record); return record; @@ -45,26 +65,31 @@ class FakePrismaService { if (!where) return null; let user: any = null; if (where.id) user = this.users.get(where.id) ?? null; - if (!user && where.email) user = Array.from(this.users.values()).find((u) => u.email === where.email) ?? null; + if (!user && where.email) + user = Array.from(this.users.values()).find((u) => u.email === where.email) ?? null; if (!user) return null; - if (where.isDeactivated !== undefined && user.isDeactivated !== where.isDeactivated) return null; + if (where.isDeactivated !== undefined && user.isDeactivated !== where.isDeactivated) + return null; if (!include) return user; const result = { ...user }; if (include.properties) result.properties = []; if (include.buyerTransactions) result.buyerTransactions = []; if (include.sellerTransactions) result.sellerTransactions = []; - if (include._count) result._count = { properties: 0, buyerTransactions: 0, sellerTransactions: 0 }; + if (include._count) + result._count = { properties: 0, buyerTransactions: 0, sellerTransactions: 0 }; return result; }, findFirst: async ({ where }: any) => { if (!where) return null; - return Array.from(this.users.values()).find((u) => { - for (const k of Object.keys(where)) { - if (k === 'NOT') continue; - if (u[k] !== where[k]) return false; - } - return true; - }) ?? null; + return ( + Array.from(this.users.values()).find((u) => { + for (const k of Object.keys(where)) { + if (k === 'NOT') continue; + if (u[k] !== where[k]) return false; + } + return true; + }) ?? null + ); }, update: async ({ where, data }: any) => { const user = this.users.get(where.id); @@ -81,7 +106,9 @@ describe('User profile e2e', () => { beforeAll(async () => { const fakePrisma = new FakePrismaService(); // Create a test user - const user = await fakePrisma.user.create({ data: { id: 'test-user-id', email: 'test@example.com' } }); + const user = await fakePrisma.user.create({ + data: { id: 'test-user-id', email: 'test@example.com' }, + }); fakePrisma.users.set('test-user-id', { ...user, id: 'test-user-id' }); const moduleRef = await Test.createTestingModule({ @@ -91,7 +118,17 @@ describe('User profile e2e', () => { ActivityLogService, SessionsService, { provide: PrismaService, useValue: fakePrisma as any }, - { provide: AuthService, useValue: { validateAccessToken: async () => ({ sub: 'test-user-id', email: 'test@example.com', role: 'USER' as any, type: 'access' }) } as any }, + { + provide: AuthService, + useValue: { + validateAccessToken: async () => ({ + sub: 'test-user-id', + email: 'test@example.com', + role: 'USER' as any, + type: 'access', + }), + } as any, + }, ], }).compile(); @@ -136,8 +173,6 @@ describe('User profile e2e', () => { }); it('rejects unauthenticated profile access', async () => { - await request(app.getHttpServer()) - .get('/users/me/profile') - .expect(401); + await request(app.getHttpServer()).get('/users/me/profile').expect(401); }); }); diff --git a/test/users/avatar-upload.spec.ts b/test/users/avatar-upload.spec.ts index 71eb4ad5..bd2e7e9c 100644 --- a/test/users/avatar-upload.spec.ts +++ b/test/users/avatar-upload.spec.ts @@ -121,18 +121,15 @@ describe('AvatarUploadController', () => { const result = await controller.deleteAvatar(filename, { user: mockUser }); - expect(avatarUploadService.deleteAvatar).toHaveBeenCalledWith( - mockUser.id, - filename, - ); + expect(avatarUploadService.deleteAvatar).toHaveBeenCalledWith(mockUser.id, filename); expect(usersService.updateAvatar).toHaveBeenCalledWith(mockUser.id, null); expect(result).toEqual({ message: 'Avatar deleted successfully' }); }); it('should throw BadRequestException when user is not authenticated', async () => { - await expect( - controller.deleteAvatar('test.jpg', { user: null } as any), - ).rejects.toThrow(BadRequestException); + await expect(controller.deleteAvatar('test.jpg', { user: null } as any)).rejects.toThrow( + BadRequestException, + ); }); });