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
15 changes: 15 additions & 0 deletions apps/backend/src/migrations/1780300000000-AddRespondedAtToParty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddRespondedAtToParty1780300000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "escrow_parties" ADD COLUMN "respondedAt" datetime`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "escrow_parties" DROP COLUMN "respondedAt"`,
);
}
}
40 changes: 40 additions & 0 deletions apps/backend/src/modules/escrow/controllers/escrow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class EscrowController {
return this.escrowService.findOverview(userId, query);
}

@Get('pending-invitations')
@ApiOperation({ summary: 'List escrows where the authenticated user has a pending party invitation' })
async getPendingInvitations(@Request() req: AuthenticatedRequest) {
return this.escrowService.getPendingInvitations(this.getAuthenticatedUserId(req));
}

@Get(':id')
@UseGuards(EscrowAccessGuard)
async findOne(@Param('id') id: string) {
Expand Down Expand Up @@ -269,6 +275,40 @@ export class EscrowController {
);
}

@Post(':id/parties/:partyId/accept')
@UseGuards(EscrowAccessGuard)
@ApiOperation({ summary: 'Accept a party invitation for an escrow' })
async acceptPartyInvitation(
@Param('id') escrowId: string,
@Param('partyId') partyId: string,
@Request() req: AuthenticatedRequest,
) {
const ipAddress = req.ip || req.socket?.remoteAddress;
return this.escrowService.acceptPartyInvitation(
escrowId,
partyId,
this.getAuthenticatedUserId(req),
ipAddress,
);
}

@Post(':id/parties/:partyId/reject')
@UseGuards(EscrowAccessGuard)
@ApiOperation({ summary: 'Reject a party invitation for an escrow' })
async rejectPartyInvitation(
@Param('id') escrowId: string,
@Param('partyId') partyId: string,
@Request() req: AuthenticatedRequest,
) {
const ipAddress = req.ip || req.socket?.remoteAddress;
return this.escrowService.rejectPartyInvitation(
escrowId,
partyId,
this.getAuthenticatedUserId(req),
ipAddress,
);
}

/**
* POST /escrows/:id/dispute
* File a dispute against an active escrow. Only a buyer or seller party may call this.
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/modules/escrow/entities/party.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export class Party {
})
status: PartyStatus;

@Column({ type: 'datetime', nullable: true })
respondedAt: Date | null;

@CreateDateColumn()
createdAt: Date;
}
2 changes: 2 additions & 0 deletions apps/backend/src/modules/escrow/escrow.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AuthModule } from '../auth/auth.module';
import { EscrowStellarIntegrationService } from './services/escrow-stellar-integration.service';
import { WebhookModule } from '../webhook/webhook.module';
import { IpfsModule } from '../ipfs/ipfs.module';
import { NotificationsModule } from '../../notifications/notifications.module';
import { User } from '../user/entities/user.entity';
import { AllowedAsset } from '../assets/entities/allowed-asset.entity';
import { EscrowLifecycleService } from './escrow-lifecycle.service';
Expand All @@ -37,6 +38,7 @@ import { EscrowQueryService } from './escrow-query.service';
AuthModule,
WebhookModule,
IpfsModule,
NotificationsModule,
],
controllers: [EscrowController, EscrowSchedulerController, EventsController],
providers: [
Expand Down
184 changes: 183 additions & 1 deletion apps/backend/src/modules/escrow/services/escrow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { WebhookService } from '../../../services/webhook/webhook.service';
import { IpfsService } from '../../ipfs/ipfs.service';
import { AllowedAsset } from '../../assets/entities/allowed-asset.entity';
import { User, UserRole } from '../../user/entities/user.entity';
import { NotificationService } from '../../../notifications/notifications.service';
import { NotificationEventType } from '../../../notifications/enums/notification-event.enum';

// ✅ FIX: missing services
import { EscrowLifecycleService } from '../escrow-lifecycle.service';
Expand All @@ -49,6 +51,7 @@ describe('EscrowService', () => {

let ipfsService: { uploadFile: jest.Mock; getGatewayUrl: jest.Mock };
let webhookService: { dispatchEvent: jest.Mock };
let notificationService: { handleEscrowEvent: jest.Mock };

// ✅ NEW MOCKS
let lifecycleService: {
Expand Down Expand Up @@ -117,6 +120,8 @@ describe('EscrowService', () => {
const mockPartyRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};

const mockConditionRepo = {
Expand Down Expand Up @@ -150,6 +155,10 @@ describe('EscrowService', () => {
getGatewayUrl: jest.fn().mockReturnValue('https://ipfs.io/ipfs/mock-cid'),
};

const mockNotificationService = {
handleEscrowEvent: jest.fn().mockResolvedValue(undefined),
};

// ---------------- NEW SERVICE MOCKS ----------------
const mockEscrowLifecycleService = {
create: jest.fn(),
Expand Down Expand Up @@ -182,6 +191,7 @@ describe('EscrowService', () => {
{ provide: getRepositoryToken(AllowedAsset), useValue: mockAssetRepo },

{ provide: IpfsService, useValue: mockIpfsService },
{ provide: NotificationService, useValue: mockNotificationService },

{
provide: EscrowStellarIntegrationService,
Expand Down Expand Up @@ -238,6 +248,7 @@ describe('EscrowService', () => {

ipfsService = module.get(IpfsService);
webhookService = module.get(WebhookService);
notificationService = module.get(NotificationService);

lifecycleService = module.get(EscrowLifecycleService);
fundingService = module.get(EscrowFundingService);
Expand All @@ -249,5 +260,176 @@ describe('EscrowService', () => {
expect(service).toBeDefined();
});

// ✅ KEEP ALL YOUR EXISTING TESTS BELOW UNCHANGED
describe('acceptPartyInvitation', () => {
const pendingParty = {
...mockParty,
status: PartyStatus.PENDING,
respondedAt: null,
escrow: {
id: 'escrow-123',
title: 'Test Escrow',
status: EscrowStatus.PENDING,
creatorId: 'user-123',
},
} as Party;

it('sets status to ACCEPTED and records respondedAt', async () => {
partyRepository.findOne.mockResolvedValue(pendingParty);
partyRepository.save.mockResolvedValue({ ...pendingParty, status: PartyStatus.ACCEPTED });
eventRepository.create.mockReturnValue({} as any);
eventRepository.save.mockResolvedValue({} as any);
userRepository.findOne.mockResolvedValue({ id: 'user-456', email: 'seller@test.com' });

const result = await service.acceptPartyInvitation('escrow-123', 'party-123', 'user-456');

expect(result.status).toBe(PartyStatus.ACCEPTED);
expect(partyRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: PartyStatus.ACCEPTED, respondedAt: expect.any(Date) }),
);
});

it('notifies the escrow creator on acceptance', async () => {
partyRepository.findOne.mockResolvedValue(pendingParty);
partyRepository.save.mockResolvedValue({ ...pendingParty, status: PartyStatus.ACCEPTED });
eventRepository.create.mockReturnValue({} as any);
eventRepository.save.mockResolvedValue({} as any);
userRepository.findOne.mockResolvedValue({ id: 'user-456', email: 'seller@test.com' });

await service.acceptPartyInvitation('escrow-123', 'party-123', 'user-456');

await new Promise(process.nextTick); // flush fire-and-forget
expect(notificationService.handleEscrowEvent).toHaveBeenCalledWith(
'user-123',
NotificationEventType.PARTY_ACCEPTED,
expect.objectContaining({ escrowId: 'escrow-123' }),
);
});

it('throws ForbiddenException when user is not the party', async () => {
partyRepository.findOne.mockResolvedValue(pendingParty);

await expect(
service.acceptPartyInvitation('escrow-123', 'party-123', 'wrong-user'),
).rejects.toThrow(ForbiddenException);
});

it('throws BadRequestException when invitation is already responded', async () => {
partyRepository.findOne.mockResolvedValue({
...pendingParty,
status: PartyStatus.ACCEPTED,
});

await expect(
service.acceptPartyInvitation('escrow-123', 'party-123', 'user-456'),
).rejects.toThrow(BadRequestException);
});

it('throws NotFoundException when party does not exist', async () => {
partyRepository.findOne.mockResolvedValue(null);

await expect(
service.acceptPartyInvitation('escrow-123', 'nonexistent', 'user-456'),
).rejects.toThrow(NotFoundException);
});
});

describe('rejectPartyInvitation', () => {
const pendingSellerParty = {
...mockParty,
status: PartyStatus.PENDING,
respondedAt: null,
escrow: {
id: 'escrow-123',
title: 'Test Escrow',
status: EscrowStatus.PENDING,
creatorId: 'user-123',
},
} as Party;

it('sets status to REJECTED and records respondedAt', async () => {
partyRepository.findOne.mockResolvedValue(pendingSellerParty);
partyRepository.save.mockResolvedValue({ ...pendingSellerParty, status: PartyStatus.REJECTED });
eventRepository.create.mockReturnValue({} as any);
eventRepository.save.mockResolvedValue({} as any);
escrowRepository.update = jest.fn().mockResolvedValue({});
userRepository.findOne.mockResolvedValue({ id: 'user-456', email: 'seller@test.com' });

const result = await service.rejectPartyInvitation('escrow-123', 'party-123', 'user-456');

expect(result.status).toBe(PartyStatus.REJECTED);
expect(partyRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: PartyStatus.REJECTED, respondedAt: expect.any(Date) }),
);
});

it('auto-cancels the escrow when a required party rejects', async () => {
partyRepository.findOne.mockResolvedValue(pendingSellerParty);
partyRepository.save.mockResolvedValue({ ...pendingSellerParty, status: PartyStatus.REJECTED });
eventRepository.create.mockReturnValue({} as any);
eventRepository.save.mockResolvedValue({} as any);
const updateMock = jest.fn().mockResolvedValue({});
escrowRepository.update = updateMock;
webhookService.dispatchEvent = jest.fn().mockResolvedValue(undefined);
userRepository.findOne.mockResolvedValue({ id: 'user-456', email: null });

await service.rejectPartyInvitation('escrow-123', 'party-123', 'user-456');

expect(updateMock).toHaveBeenCalledWith(
'escrow-123',
expect.objectContaining({ status: EscrowStatus.CANCELLED }),
);
});

it('throws ForbiddenException when user is not the party', async () => {
partyRepository.findOne.mockResolvedValue(pendingSellerParty);

await expect(
service.rejectPartyInvitation('escrow-123', 'party-123', 'wrong-user'),
).rejects.toThrow(ForbiddenException);
});
});

describe('getPendingInvitations', () => {
it('returns pending party invitations for user', async () => {
const pendingParties = [
{ ...mockParty, status: PartyStatus.PENDING, escrow: { id: 'escrow-123' } },
];
partyRepository.find.mockResolvedValue(pendingParties);

const result = await service.getPendingInvitations('user-456');

expect(partyRepository.find).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'user-456', status: PartyStatus.PENDING } }),
);
expect(result).toHaveLength(1);
});

it('returns empty array when no pending invitations', async () => {
partyRepository.find.mockResolvedValue([]);

const result = await service.getPendingInvitations('user-456');

expect(result).toHaveLength(0);
});
});

describe('fund - party acceptance gate', () => {
it('throws BadRequestException when seller has not accepted', async () => {
const escrowWithPendingSeller = {
...mockEscrow,
status: EscrowStatus.PENDING,
stellarTxHash: null,
amount: 100,
parties: [
{ role: PartyRole.BUYER, status: PartyStatus.ACCEPTED, userId: 'user-123' },
{ role: PartyRole.SELLER, status: PartyStatus.PENDING, userId: 'user-456' },
],
};
escrowRepository.findOne.mockResolvedValue(escrowWithPendingSeller);

await expect(
service.fund('escrow-123', { amount: 100 } as any, 'user-123', 'wallet-addr'),
).rejects.toThrow(BadRequestException);
});
});
});
Loading
Loading