diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index f14701df..f0e6793a 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; // AES-256-GCM ciphertext produced by contact-encryption.util: iv:authTag:ciphertext // IV = 12 bytes (24 hex), tag = 16 bytes (32 hex), ciphertext = 1+ hex chars. diff --git a/src/workers/tracking-poll.worker.ts b/src/workers/tracking-poll.worker.ts index 3c336fd1..294538d1 100644 --- a/src/workers/tracking-poll.worker.ts +++ b/src/workers/tracking-poll.worker.ts @@ -56,8 +56,13 @@ export class TrackingPollWorker implements OnModuleInit, OnApplicationShutdown { } const deliveredAt = new Date(); - await this.escrowRepository.markDelivered(escrow.id, deliveredAt); + // Call the contract first so a failure leaves the escrow in SHIPPED + // state, allowing the next poll cycle to retry. Only mark as + // DELIVERED in the database after the chain call succeeds. + // This follows the same claim-and-release pattern used by + // AutoReleaseWorker (see auto-release.worker.ts). await this.contractService.recordDelivery(escrow.id); + await this.escrowRepository.markDelivered(escrow.id, deliveredAt); } catch (error) { this.logger.error( JSON.stringify({ diff --git a/test/unit/api-keys.controller.spec.ts b/test/unit/api-keys.controller.spec.ts index 39b8d402..e89d3f52 100644 --- a/test/unit/api-keys.controller.spec.ts +++ b/test/unit/api-keys.controller.spec.ts @@ -88,9 +88,8 @@ describe('ApiKeysController (issue #410)', () => { expect(JSON.stringify(res.body)).not.toContain('old:enc:key'); spy.mockRestore(); -import { ApiKeysController } from '../../src/admin/api-keys/api-keys.controller'; -import { LogisticsService } from '../../src/logistics/logistics.service'; -import { RotateApiKeyDto } from '../../src/admin/api-keys/dto/rotate-api-key.dto'; + }); +}); describe('ApiKeysController (issue #498)', () => { function buildDto(key: string): RotateApiKeyDto { diff --git a/test/unit/escrow.service.spec.ts b/test/unit/escrow.service.spec.ts index 4aecad21..9a2d1cd0 100644 --- a/test/unit/escrow.service.spec.ts +++ b/test/unit/escrow.service.spec.ts @@ -122,9 +122,10 @@ describe('EscrowService.handleShipment (issue #16)', () => { currency: 'USDC', buyerAddress: 'buyer-address', }; - const createdEscrow = { + const createdEscrow: EscrowRecord = { ...fundedEscrow, id: 'escrow-2', + state: 'CREATED', }; repository.findByVendorAndItem.mockResolvedValue(null); repository.create.mockResolvedValue(createdEscrow); @@ -139,7 +140,7 @@ describe('EscrowService.handleShipment (issue #16)', () => { }), ); expect(repository.create).toHaveBeenCalledWith(createDto, 'vendor-address'); - expect(notifications.notifyFunded).toHaveBeenCalledWith(createdEscrow); + expect(notifications.notifyFunded).not.toHaveBeenCalled(); }); it('throws ConflictException for duplicate escrow references', async () => { @@ -397,182 +398,6 @@ describe('EscrowService.handleShipment (issue #16)', () => { ).rejects.toThrow(NotFoundException); }); - it('creates a new escrow and returns a payment URL', async () => { - const createDto = { - itemName: 'Leather bag', - itemRef: 'bag-123', - amount: 125, - currency: 'USDC', - buyerAddress: 'buyer-address', - }; - const createdEscrow = { - ...fundedEscrow, - id: 'escrow-2', - }; - repository.findByVendorAndItem.mockResolvedValue(null); - repository.create.mockResolvedValue(createdEscrow); - notifications.notifyFunded.mockResolvedValue(); - - await expect( - service.createEscrow(createDto as any, 'vendor-address'), - ).resolves.toEqual( - expect.objectContaining({ - id: 'escrow-2', - paymentUrl: 'https://trust-link.local/pay/escrow-2', - }), - ); - expect(repository.create).toHaveBeenCalledWith(createDto, 'vendor-address'); - expect(notifications.notifyFunded).toHaveBeenCalledWith(createdEscrow); - }); - - it('throws ConflictException for duplicate escrow references', async () => { - const createDto = { - itemName: 'Leather bag', - itemRef: 'bag-123', - amount: 125, - currency: 'USDC', - buyerAddress: 'buyer-address', - }; - repository.findByVendorAndItem.mockResolvedValue(fundedEscrow); - - await expect( - service.createEscrow(createDto as any, 'vendor-address'), - ).rejects.toThrow(ConflictException); - expect(repository.create).not.toHaveBeenCalled(); - }); - - it('throws BadRequestException for invalid amount', async () => { - const createDto = { - itemName: 'Leather bag', - itemRef: 'bag-123', - amount: 0, - currency: 'USDC', - buyerAddress: 'buyer-address', - }; - repository.findByVendorAndItem.mockResolvedValue(null); - - await expect( - service.createEscrow(createDto as any, 'vendor-address'), - ).rejects.toThrow(BadRequestException); - expect(repository.create).not.toHaveBeenCalled(); - }); -}); -import { - BadRequestException, - ForbiddenException, - NotFoundException, - ConflictException, -} from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { NotificationsService } from '../../src/notifications/notifications.service'; -import { EscrowRecord } from '../../src/prisma/prisma.service'; -import { EscrowRepository } from '../../src/escrow/escrow.repository'; -import { EscrowService } from '../../src/escrow/escrow.service'; -import { S3PresignService } from '../../src/common/services/s3-presign.service'; -import { ContractService } from '../../src/stellar/contract.service'; - -describe('EscrowService.handleShipment (issue #16)', () => { - let service: EscrowService; - let repository: jest.Mocked; - let notifications: jest.Mocked; - - const fundedEscrow: EscrowRecord = { - id: 'escrow-1', - itemName: 'Leather bag', - itemRef: 'bag-123', - amount: 125, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'FUNDED', - trackingId: null, - shippedAt: null, - deliveredAt: null, - deliveryRecordedAt: null, - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - }; - - beforeEach(async () => { - repository = { - create: jest.fn(), - findById: jest.fn(), - findByVendorAndItem: jest.fn(), - markShipped: jest.fn(), - } as unknown as jest.Mocked; - notifications = { - notifyFunded: jest.fn(), - notifyShipped: jest.fn(), - } as unknown as jest.Mocked; - - const moduleRef = await Test.createTestingModule({ - providers: [ - EscrowService, - { provide: EscrowRepository, useValue: repository }, - { provide: NotificationsService, useValue: notifications }, - { provide: S3PresignService, useValue: {} }, - { provide: ContractService, useValue: {} }, - ], - }).compile(); - - service = moduleRef.get(EscrowService); - }); - - it('updates escrow state and sends a shipment notification', async () => { - const shipped = { - ...fundedEscrow, - state: 'SHIPPED' as const, - trackingId: 'TRK-123', - }; - repository.findById.mockResolvedValue(fundedEscrow); - repository.markShipped.mockResolvedValue(shipped); - notifications.notifyShipped.mockResolvedValue(); - - await expect( - service.handleShipment('escrow-1', 'vendor-address', 'TRK-123'), - ).resolves.toEqual(shipped); - - expect(repository.markShipped).toHaveBeenCalledWith('escrow-1', 'TRK-123'); - expect(notifications.notifyShipped).toHaveBeenCalledWith(shipped); - }); - - it('throws ForbiddenException for the wrong vendor', async () => { - repository.findById.mockResolvedValue(fundedEscrow); - - await expect( - service.handleShipment('escrow-1', 'other-vendor', 'TRK-123'), - ).rejects.toThrow(ForbiddenException); - }); - - it('throws BadRequestException when escrow is not funded', async () => { - repository.findById.mockResolvedValue({ - ...fundedEscrow, - state: 'SHIPPED', - }); - - await expect( - service.handleShipment('escrow-1', 'vendor-address', 'TRK-123'), - ).rejects.toThrow(ConflictException); - }); - - it('throws BadRequestException for an empty tracking ID', async () => { - await expect( - service.handleShipment('escrow-1', 'vendor-address', ' '), - ).rejects.toThrow(BadRequestException); - expect(repository.findById).not.toHaveBeenCalled(); - }); - - it('keeps not-found escrow errors explicit', async () => { - repository.findById.mockResolvedValue(null); - - await expect( - service.handleShipment('missing', 'vendor-address', 'TRK-123'), - ).rejects.toThrow(NotFoundException); - }); - it('creates a new escrow and returns a payment URL', async () => { const createDto = { itemName: 'Leather bag', diff --git a/test/unit/prisma.service.spec.ts b/test/unit/prisma.service.spec.ts index 45dd3f19..9825b612 100644 --- a/test/unit/prisma.service.spec.ts +++ b/test/unit/prisma.service.spec.ts @@ -9,7 +9,7 @@ describe('PrismaService in-memory stores (issue #411)', () => { }); it('assertEncryptedContact throws when plaintext email or phone is written', async () => { - await expect( + expect(() => prisma.escrow.create({ data: { itemName: 'Item', @@ -21,10 +21,10 @@ describe('PrismaService in-memory stores (issue #411)', () => { buyerContactEmail: 'plain@example.com', }, }), - ).rejects.toThrow(/must be encrypted/); + ).toThrow(/must be encrypted/); // phone plaintext - await expect( + expect(() => prisma.escrow.create({ data: { itemName: 'Item', @@ -35,7 +35,7 @@ describe('PrismaService in-memory stores (issue #411)', () => { buyerContactPhone: '+1234567890', }, }), - ).rejects.toThrow(/must be encrypted/); + ).toThrow(/must be encrypted/); }); it('create/findUnique/findMany/update for escrow and updateMany behavior', async () => { diff --git a/test/unit/tracking-poll.worker.spec.ts b/test/unit/tracking-poll.worker.spec.ts index 69aed25a..50ca1b5d 100644 --- a/test/unit/tracking-poll.worker.spec.ts +++ b/test/unit/tracking-poll.worker.spec.ts @@ -63,11 +63,13 @@ describe('TrackingPollWorker (issue #11)', () => { await worker.run(); + // Delivery is recorded on-chain BEFORE marking as DELIVERED in the DB + // so that a contract failure leaves the escrow retryable. + expect(contractService.recordDelivery).toHaveBeenCalledWith('escrow-1'); expect(escrowRepository.markDelivered).toHaveBeenCalledWith( 'escrow-1', expect.any(Date), ); - expect(contractService.recordDelivery).toHaveBeenCalledWith('escrow-1'); }); it('keeps polling resilient to carrier API failures', async () => {