From 5ae0dc97c217f6e699e301f3054310dd57362141 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Wed, 29 Apr 2026 00:51:39 +0100 Subject: [PATCH 1/3] Add transaction status lifecycle enforcement --- .../migration.sql | 15 +++ prisma/schema.prisma | 1 - src/admin/admin.controller.ts | 9 ++ src/admin/admin.module.ts | 3 +- src/admin/admin.service.ts | 31 ++--- src/admin/dto/admin.dto.ts | 5 + .../transaction-status.constants.ts | 20 +++ src/transactions/transactions.module.ts | 10 ++ src/transactions/transactions.service.ts | 59 +++++++++ src/types/prisma.types.ts | 1 - test/admin/transaction-status.dto.spec.ts | 23 ++++ .../transactions/transactions.service.spec.ts | 116 ++++++++++++++++++ 12 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql create mode 100644 src/transactions/transaction-status.constants.ts create mode 100644 src/transactions/transactions.module.ts create mode 100644 src/transactions/transactions.service.ts create mode 100644 test/admin/transaction-status.dto.spec.ts create mode 100644 test/transactions/transactions.service.spec.ts diff --git a/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql b/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql new file mode 100644 index 00000000..592ed6bf --- /dev/null +++ b/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql @@ -0,0 +1,15 @@ +UPDATE "transactions" +SET "status" = 'CANCELLED' +WHERE "status" = 'FAILED'; + +ALTER TYPE "TransactionStatus" RENAME TO "TransactionStatus_old"; + +CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'COMPLETED', 'CANCELLED'); + +ALTER TABLE "transactions" +ALTER COLUMN "status" DROP DEFAULT, +ALTER COLUMN "status" TYPE "TransactionStatus" +USING ("status"::text::"TransactionStatus"), +ALTER COLUMN "status" SET DEFAULT 'PENDING'; + +DROP TYPE "TransactionStatus_old"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11e5b6e9..122eabe9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,7 +45,6 @@ enum TransactionStatus { PENDING COMPLETED CANCELLED - FAILED } // Document types diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index ae036cec..1d6af08a 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -18,6 +18,7 @@ import { ModerationQueueQueryDto, ReviewFraudAlertDto, TransactionMonitoringQueryDto, + UpdateTransactionStatusDto, } from './dto/admin.dto'; import { RestoreBackupDto, UpdateBackupScheduleDto } from '../backup/dto/backup.dto'; @@ -127,6 +128,14 @@ export class AdminController { return this.adminService.transactionMonitoringSummary(); } + @Patch('transactions/:id/status') + updateTransactionStatus( + @Param('id') transactionId: string, + @Body() payload: UpdateTransactionStatusDto, + ) { + return this.adminService.updateTransactionStatus(transactionId, payload); + } + @Get('fraud/alerts') listFraudAlerts(@Query() query: FraudAlertsQueryDto) { return this.adminService.listFraudAlerts(query); diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 0d21a180..98a91c2a 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -4,9 +4,10 @@ import { AdminService } from './admin.service'; import { PrismaModule } from '../database/prisma.module'; import { FraudModule } from '../fraud/fraud.module'; import { BackupModule } from '../backup/backup.module'; +import { TransactionsModule } from '../transactions/transactions.module'; @Module({ - imports: [PrismaModule, FraudModule, BackupModule], + imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index dafdd7ac..e13ad41e 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -13,9 +13,11 @@ import { ModerationQueueQueryDto, ReviewFraudAlertDto, TransactionMonitoringQueryDto, + UpdateTransactionStatusDto, } from './dto/admin.dto'; -import { PropertyStatus } from '../types/prisma.types'; +import { PropertyStatus, TransactionStatus, TransactionType } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; +import { TransactionsService } from '../transactions/transactions.service'; @Injectable() export class AdminService { @@ -23,6 +25,7 @@ export class AdminService { private readonly prisma: PrismaService, private readonly fraudService: FraudService, private readonly backupService: BackupService, + private readonly transactionsService: TransactionsService, ) {} async listBackups() { @@ -70,15 +73,14 @@ export class AdminService { salesAggregate, rentAggregate, ] = await Promise.all([ - this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), - this.prisma.transaction.count({ where: { status: 'PENDING' } }), - this.prisma.transaction.count({ where: { status: 'FAILED' } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'SALE' }, + where: { status: TransactionStatus.COMPLETED, type: TransactionType.SALE }, _sum: { amount: true }, }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'TRANSFER' }, + where: { status: TransactionStatus.COMPLETED, type: TransactionType.TRANSFER }, _sum: { amount: true }, }), ]); @@ -101,7 +103,6 @@ export class AdminService { systemHealth: { completedTransactions, pendingTransactions, - failedTransactions, }, }; } @@ -310,13 +311,12 @@ export class AdminService { } async transactionMonitoringSummary() { - const [pending, completed, cancelled, failed, aggregateValue] = await Promise.all([ - this.prisma.transaction.count({ where: { status: 'PENDING' } }), - this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), - this.prisma.transaction.count({ where: { status: 'CANCELLED' } }), - this.prisma.transaction.count({ where: { status: 'FAILED' } }), + const [pending, completed, cancelled, aggregateValue] = await Promise.all([ + this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.CANCELLED } }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED' }, + where: { status: TransactionStatus.COMPLETED }, _sum: { amount: true }, }), ]); @@ -325,11 +325,14 @@ export class AdminService { pending, completed, cancelled, - failed, totalCompletedValue: aggregateValue._sum.amount ?? 0, }; } + async updateTransactionStatus(transactionId: string, payload: UpdateTransactionStatusDto) { + return this.transactionsService.updateTransactionStatus(transactionId, payload.status); + } + async listFraudAlerts(query: FraudAlertsQueryDto) { return this.fraudService.listAlerts(query); } diff --git a/src/admin/dto/admin.dto.ts b/src/admin/dto/admin.dto.ts index e664db89..c4200ccc 100644 --- a/src/admin/dto/admin.dto.ts +++ b/src/admin/dto/admin.dto.ts @@ -139,6 +139,11 @@ export class TransactionMonitoringQueryDto { limit: number = 20; } +export class UpdateTransactionStatusDto { + @IsEnum(TransactionStatus) + status!: TransactionStatus; +} + export { AddFraudInvestigationNoteDto, BlockFraudUserDto, diff --git a/src/transactions/transaction-status.constants.ts b/src/transactions/transaction-status.constants.ts new file mode 100644 index 00000000..103e15d8 --- /dev/null +++ b/src/transactions/transaction-status.constants.ts @@ -0,0 +1,20 @@ +import { TransactionStatus } from '../types/prisma.types'; + +export const DEFAULT_TRANSACTION_STATUS = TransactionStatus.PENDING; + +const ALLOWED_TRANSACTION_STATUS_TRANSITIONS: Record = { + [TransactionStatus.PENDING]: [TransactionStatus.COMPLETED, TransactionStatus.CANCELLED], + [TransactionStatus.COMPLETED]: [], + [TransactionStatus.CANCELLED]: [], +}; + +export function canTransitionTransactionStatus( + currentStatus: TransactionStatus, + nextStatus: TransactionStatus, +): boolean { + if (currentStatus === nextStatus) { + return true; + } + + return ALLOWED_TRANSACTION_STATUS_TRANSITIONS[currentStatus].includes(nextStatus); +} diff --git a/src/transactions/transactions.module.ts b/src/transactions/transactions.module.ts new file mode 100644 index 00000000..d4bd349f --- /dev/null +++ b/src/transactions/transactions.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../database/prisma.module'; +import { TransactionsService } from './transactions.service'; + +@Module({ + imports: [PrismaModule], + providers: [TransactionsService], + exports: [TransactionsService], +}) +export class TransactionsModule {} diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts new file mode 100644 index 00000000..a2910822 --- /dev/null +++ b/src/transactions/transactions.service.ts @@ -0,0 +1,59 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../database/prisma.service'; +import { TransactionStatus, TransactionType } from '../types/prisma.types'; +import { + canTransitionTransactionStatus, + DEFAULT_TRANSACTION_STATUS, +} from './transaction-status.constants'; + +export interface CreateTransactionInput { + propertyId: string; + buyerId: string; + sellerId: string; + amount: Decimal | number | string; + type: TransactionType; + status?: TransactionStatus; + blockchainHash?: string | null; + contractAddress?: string | null; + notes?: string | null; +} + +@Injectable() +export class TransactionsService { + constructor(private readonly prisma: PrismaService) {} + + async createTransaction(input: CreateTransactionInput) { + return this.prisma.transaction.create({ + data: { + ...input, + status: input.status ?? DEFAULT_TRANSACTION_STATUS, + }, + }); + } + + async updateTransactionStatus(transactionId: string, status: TransactionStatus) { + const transaction = await this.prisma.transaction.findUnique({ + where: { id: transactionId }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${transactionId} not found`); + } + + if (!canTransitionTransactionStatus(transaction.status as TransactionStatus, status)) { + throw new BadRequestException( + `Transaction status cannot transition from ${transaction.status} to ${status}`, + ); + } + + if (transaction.status === status) { + return transaction; + } + + return this.prisma.transaction.update({ + where: { id: transactionId }, + data: { status }, + }); + } +} diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 5a13931c..d6513dc5 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -78,7 +78,6 @@ export enum TransactionStatus { PENDING = 'PENDING', COMPLETED = 'COMPLETED', CANCELLED = 'CANCELLED', - FAILED = 'FAILED', } export enum FraudSeverity { diff --git a/test/admin/transaction-status.dto.spec.ts b/test/admin/transaction-status.dto.spec.ts new file mode 100644 index 00000000..83aa763d --- /dev/null +++ b/test/admin/transaction-status.dto.spec.ts @@ -0,0 +1,23 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { ArgumentMetadata } from '@nestjs/common/interfaces'; +import { UpdateTransactionStatusDto } from '../../src/admin/dto/admin.dto'; + +describe('UpdateTransactionStatusDto', () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + + const metadata: ArgumentMetadata = { + type: 'body', + metatype: UpdateTransactionStatusDto, + data: '', + }; + + it('rejects invalid transaction status values', async () => { + await expect(pipe.transform({ status: 'FAILED' }, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/test/transactions/transactions.service.spec.ts b/test/transactions/transactions.service.spec.ts new file mode 100644 index 00000000..b35c77ba --- /dev/null +++ b/test/transactions/transactions.service.spec.ts @@ -0,0 +1,116 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../src/database/prisma.service'; +import { TransactionStatus, TransactionType } from '../../src/types/prisma.types'; +import { TransactionsService } from '../../src/transactions/transactions.service'; + +describe('TransactionsService', () => { + let service: TransactionsService; + + const mockPrismaService = { + transaction: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionsService, + { provide: PrismaService, useValue: mockPrismaService }, + ], + }).compile(); + + service = module.get(TransactionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates transactions with PENDING status by default', async () => { + mockPrismaService.transaction.create.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.PENDING, + }); + + await service.createTransaction({ + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }); + + expect(mockPrismaService.transaction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + status: TransactionStatus.PENDING, + }), + }); + }); + + it('allows a valid transition from PENDING to COMPLETED', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.PENDING, + }); + mockPrismaService.transaction.update.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.COMPLETED, + }); + + const result = await service.updateTransactionStatus('txn-1', TransactionStatus.COMPLETED); + + expect(mockPrismaService.transaction.update).toHaveBeenCalledWith({ + where: { id: 'txn-1' }, + data: { status: TransactionStatus.COMPLETED }, + }); + expect(result.status).toBe(TransactionStatus.COMPLETED); + }); + + it('allows a valid transition from PENDING to CANCELLED', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-2', + status: TransactionStatus.PENDING, + }); + mockPrismaService.transaction.update.mockResolvedValue({ + id: 'txn-2', + status: TransactionStatus.CANCELLED, + }); + + const result = await service.updateTransactionStatus('txn-2', TransactionStatus.CANCELLED); + + expect(mockPrismaService.transaction.update).toHaveBeenCalledWith({ + where: { id: 'txn-2' }, + data: { status: TransactionStatus.CANCELLED }, + }); + expect(result.status).toBe(TransactionStatus.CANCELLED); + }); + + it('rejects invalid status transitions', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-3', + status: TransactionStatus.COMPLETED, + }); + + await expect( + service.updateTransactionStatus('txn-3', TransactionStatus.CANCELLED), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrismaService.transaction.update).not.toHaveBeenCalled(); + }); + + it('rejects updates for missing transactions', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect( + service.updateTransactionStatus('missing-txn', TransactionStatus.COMPLETED), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); From aba8e1e0eff8874adefb92f1bcbe0dbc9f033654 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Wed, 29 Apr 2026 01:01:07 +0100 Subject: [PATCH 2/3] Fix admin dashboard transaction aggregate tuple --- src/admin/admin.service.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index e13ad41e..5af55ffd 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -66,13 +66,8 @@ export class AdminService { this.prisma.property.count({ where: { status: PropertyStatus.ACTIVE } }), ]); - const [ - completedTransactions, - pendingTransactions, - failedTransactions, - salesAggregate, - rentAggregate, - ] = await Promise.all([ + const [completedTransactions, pendingTransactions, salesAggregate, rentAggregate] = + await Promise.all([ this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), this.prisma.transaction.aggregate({ From 2ceb0d419595ff97e814e172be08b7b12ced3d21 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Wed, 29 Apr 2026 01:15:41 +0100 Subject: [PATCH 3/3] Add transaction record creation endpoint --- src/transactions/dto/transaction.dto.ts | 23 ++ src/transactions/transactions.controller.ts | 17 ++ src/transactions/transactions.module.ts | 2 + src/transactions/transactions.service.ts | 98 +++++++- .../create-transaction.dto.spec.ts | 35 +++ .../transactions/transactions.service.spec.ts | 238 +++++++++++++++++- 6 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 src/transactions/dto/transaction.dto.ts create mode 100644 src/transactions/transactions.controller.ts create mode 100644 test/transactions/create-transaction.dto.spec.ts diff --git a/src/transactions/dto/transaction.dto.ts b/src/transactions/dto/transaction.dto.ts new file mode 100644 index 00000000..86e1a90a --- /dev/null +++ b/src/transactions/dto/transaction.dto.ts @@ -0,0 +1,23 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsPositive, IsUUID } from 'class-validator'; +import { TransactionType } from '../../types/prisma.types'; + +export class CreateTransactionDto { + @IsUUID('4') + propertyId!: string; + + @IsUUID('4') + buyerId!: string; + + @IsUUID('4') + sellerId!: string; + + @Type(() => Number) + @IsNumber() + @IsPositive() + amount!: number; + + @IsOptional() + @IsEnum(TransactionType) + type?: TransactionType; +} diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts new file mode 100644 index 00000000..4dc0e909 --- /dev/null +++ b/src/transactions/transactions.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { CreateTransactionDto } from './dto/transaction.dto'; +import { TransactionsService } from './transactions.service'; + +@Controller('transactions') +export class TransactionsController { + constructor(private readonly transactionsService: TransactionsService) {} + + @UseGuards(JwtAuthGuard) + @Post() + create(@Body() createTransactionDto: CreateTransactionDto, @CurrentUser() user: AuthUserPayload) { + return this.transactionsService.createTransaction(createTransactionDto, user); + } +} diff --git a/src/transactions/transactions.module.ts b/src/transactions/transactions.module.ts index d4bd349f..371fd5a2 100644 --- a/src/transactions/transactions.module.ts +++ b/src/transactions/transactions.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../database/prisma.module'; +import { TransactionsController } from './transactions.controller'; import { TransactionsService } from './transactions.service'; @Module({ imports: [PrismaModule], + controllers: [TransactionsController], providers: [TransactionsService], exports: [TransactionsService], }) diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index a2910822..492a5915 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -1,7 +1,13 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; import { PrismaService } from '../database/prisma.service'; -import { TransactionStatus, TransactionType } from '../types/prisma.types'; +import { TransactionStatus, TransactionType, UserRole } from '../types/prisma.types'; import { canTransitionTransactionStatus, DEFAULT_TRANSACTION_STATUS, @@ -12,7 +18,7 @@ export interface CreateTransactionInput { buyerId: string; sellerId: string; amount: Decimal | number | string; - type: TransactionType; + type?: TransactionType; status?: TransactionStatus; blockchainHash?: string | null; contractAddress?: string | null; @@ -23,12 +29,94 @@ export interface CreateTransactionInput { export class TransactionsService { constructor(private readonly prisma: PrismaService) {} - async createTransaction(input: CreateTransactionInput) { + async createTransaction(input: CreateTransactionInput, actor?: AuthUserPayload) { + if (actor && actor.role !== UserRole.ADMIN && actor.sub !== input.buyerId) { + throw new ForbiddenException('You can only create transactions as the authenticated buyer'); + } + + const [property, buyer, seller] = await Promise.all([ + this.prisma.property.findUnique({ + where: { id: input.propertyId }, + select: { + id: true, + title: true, + address: true, + ownerId: true, + }, + }), + this.prisma.user.findUnique({ + where: { id: input.buyerId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }), + this.prisma.user.findUnique({ + where: { id: input.sellerId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }), + ]); + + if (!property) { + throw new NotFoundException(`Property ${input.propertyId} not found`); + } + + if (!buyer) { + throw new NotFoundException(`Buyer ${input.buyerId} not found`); + } + + if (!seller) { + throw new NotFoundException(`Seller ${input.sellerId} not found`); + } + + if (property.ownerId !== input.sellerId) { + throw new BadRequestException('Seller must match the property owner'); + } + return this.prisma.transaction.create({ data: { - ...input, + propertyId: input.propertyId, + buyerId: input.buyerId, + sellerId: input.sellerId, + amount: new Decimal(input.amount.toString()), + type: input.type ?? TransactionType.SALE, + blockchainHash: input.blockchainHash, + contractAddress: input.contractAddress, + notes: input.notes, status: input.status ?? DEFAULT_TRANSACTION_STATUS, }, + include: { + property: { + select: { + id: true, + title: true, + address: true, + }, + }, + buyer: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + seller: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, }); } diff --git a/test/transactions/create-transaction.dto.spec.ts b/test/transactions/create-transaction.dto.spec.ts new file mode 100644 index 00000000..61a3799e --- /dev/null +++ b/test/transactions/create-transaction.dto.spec.ts @@ -0,0 +1,35 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { ArgumentMetadata } from '@nestjs/common/interfaces'; +import { CreateTransactionDto } from '../../src/transactions/dto/transaction.dto'; + +describe('CreateTransactionDto', () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + + const metadata: ArgumentMetadata = { + type: 'body', + metatype: CreateTransactionDto, + data: '', + }; + + it('rejects missing required fields', async () => { + await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects invalid amounts', async () => { + await expect( + pipe.transform( + { + propertyId: '11111111-1111-4111-8111-111111111111', + buyerId: '22222222-2222-4222-8222-222222222222', + sellerId: '33333333-3333-4333-8333-333333333333', + amount: 0, + }, + metadata, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/test/transactions/transactions.service.spec.ts b/test/transactions/transactions.service.spec.ts index b35c77ba..601b652b 100644 --- a/test/transactions/transactions.service.spec.ts +++ b/test/transactions/transactions.service.spec.ts @@ -1,13 +1,19 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../../src/database/prisma.service'; -import { TransactionStatus, TransactionType } from '../../src/types/prisma.types'; +import { TransactionStatus, TransactionType, UserRole } from '../../src/types/prisma.types'; import { TransactionsService } from '../../src/transactions/transactions.service'; describe('TransactionsService', () => { let service: TransactionsService; const mockPrismaService = { + property: { + findUnique: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, transaction: { create: jest.fn(), findUnique: jest.fn(), @@ -30,30 +36,244 @@ describe('TransactionsService', () => { jest.clearAllMocks(); }); + it('creates a transaction linked to property, buyer, seller, and amount', async () => { + mockPrismaService.property.findUnique.mockResolvedValue({ + id: 'property-1', + title: 'Ocean View', + address: '123 Coast St', + ownerId: 'seller-1', + }); + mockPrismaService.user.findUnique + .mockResolvedValueOnce({ + id: 'buyer-1', + firstName: 'Buyer', + lastName: 'One', + email: 'buyer@example.com', + }) + .mockResolvedValueOnce({ + id: 'seller-1', + firstName: 'Seller', + lastName: 'One', + email: 'seller@example.com', + }); + mockPrismaService.transaction.create.mockResolvedValue({ + id: 'txn-1', + amount: { toString: () => '1000' }, + property: { + id: 'property-1', + title: 'Ocean View', + address: '123 Coast St', + }, + buyer: { + id: 'buyer-1', + firstName: 'Buyer', + lastName: 'One', + email: 'buyer@example.com', + }, + seller: { + id: 'seller-1', + firstName: 'Seller', + lastName: 'One', + email: 'seller@example.com', + }, + }); + + const result = await service.createTransaction( + { + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }, + { + sub: 'buyer-1', + email: 'buyer@example.com', + role: UserRole.USER, + type: 'access', + }, + ); + + expect(mockPrismaService.transaction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + type: TransactionType.SALE, + status: TransactionStatus.PENDING, + }), + include: expect.objectContaining({ + property: expect.any(Object), + buyer: expect.any(Object), + seller: expect.any(Object), + }), + }); + expect(result).toEqual( + expect.objectContaining({ + amount: expect.objectContaining({ toString: expect.any(Function) }), + property: expect.objectContaining({ id: 'property-1' }), + buyer: expect.objectContaining({ id: 'buyer-1' }), + seller: expect.objectContaining({ id: 'seller-1' }), + }), + ); + }); + it('creates transactions with PENDING status by default', async () => { + mockPrismaService.property.findUnique.mockResolvedValue({ + id: 'property-1', + title: 'Property', + address: '123 Main St', + ownerId: 'seller-1', + }); + mockPrismaService.user.findUnique + .mockResolvedValueOnce({ + id: 'buyer-1', + firstName: 'Buyer', + lastName: 'One', + email: 'buyer@example.com', + }) + .mockResolvedValueOnce({ + id: 'seller-1', + firstName: 'Seller', + lastName: 'One', + email: 'seller@example.com', + }); mockPrismaService.transaction.create.mockResolvedValue({ id: 'txn-1', status: TransactionStatus.PENDING, }); - await service.createTransaction({ - propertyId: 'property-1', - buyerId: 'buyer-1', - sellerId: 'seller-1', - amount: 1000, - type: TransactionType.SALE, - }); + await service.createTransaction( + { + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }, + { + sub: 'buyer-1', + email: 'buyer@example.com', + role: UserRole.USER, + type: 'access', + }, + ); expect(mockPrismaService.transaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ propertyId: 'property-1', buyerId: 'buyer-1', sellerId: 'seller-1', - amount: 1000, type: TransactionType.SALE, status: TransactionStatus.PENDING, }), + include: expect.any(Object), + }); + }); + + it('rejects invalid property references', async () => { + mockPrismaService.property.findUnique.mockResolvedValue(null); + mockPrismaService.user.findUnique + .mockResolvedValueOnce({ + id: 'buyer-1', + firstName: 'Buyer', + lastName: 'One', + email: 'buyer@example.com', + }) + .mockResolvedValueOnce({ + id: 'seller-1', + firstName: 'Seller', + lastName: 'One', + email: 'seller@example.com', + }); + + await expect( + service.createTransaction( + { + propertyId: 'missing-property', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }, + { + sub: 'buyer-1', + email: 'buyer@example.com', + role: UserRole.USER, + type: 'access', + }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects invalid buyer references', async () => { + mockPrismaService.property.findUnique.mockResolvedValue({ + id: 'property-1', + title: 'Property', + address: '123 Main St', + ownerId: 'seller-1', }); + mockPrismaService.user.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'seller-1', + firstName: 'Seller', + lastName: 'One', + email: 'seller@example.com', + }); + + await expect( + service.createTransaction( + { + propertyId: 'property-1', + buyerId: 'missing-buyer', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }, + { + sub: 'missing-buyer', + email: 'buyer@example.com', + role: UserRole.USER, + type: 'access', + }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects invalid seller references', async () => { + mockPrismaService.property.findUnique.mockResolvedValue({ + id: 'property-1', + title: 'Property', + address: '123 Main St', + ownerId: 'seller-1', + }); + mockPrismaService.user.findUnique + .mockResolvedValueOnce({ + id: 'buyer-1', + firstName: 'Buyer', + lastName: 'One', + email: 'buyer@example.com', + }) + .mockResolvedValueOnce(null); + + await expect( + service.createTransaction( + { + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'missing-seller', + amount: 1000, + type: TransactionType.SALE, + }, + { + sub: 'buyer-1', + email: 'buyer@example.com', + role: UserRole.USER, + type: 'access', + }, + ), + ).rejects.toBeInstanceOf(NotFoundException); }); it('allows a valid transition from PENDING to COMPLETED', async () => {