diff --git a/src/escrow/escrow.repository.ts b/src/escrow/escrow.repository.ts index f53c29ea..69ee5532 100644 --- a/src/escrow/escrow.repository.ts +++ b/src/escrow/escrow.repository.ts @@ -273,17 +273,52 @@ export class EscrowRepository { } /** - * Returns all SHIPPED escrows that have a non-null trackingId, + * Returns all SHIPPED escrows that have a non-null trackingId and have not + * been claimed for delivery recording (deliveryRecordedAt is null), * used by the tracking poll worker to check for delivery updates. */ findShippedWithTracking(): Promise { return this.prisma.escrow - .findMany({ where: { state: 'SHIPPED' } }) + .findMany({ + where: { state: 'SHIPPED', deliveryRecordedAt: null }, + }) .then((escrows) => escrows.filter((escrow) => Boolean(escrow.trackingId)), ); } + /** + * Atomically claims an escrow for delivery recording by setting + * deliveryRecordedAt. Returns null if the escrow is already claimed + * (deliveryRecordedAt is not null). + * Follows the same claim-and-release pattern as markAutoReleaseSubmitting. + */ + async claimDelivery(id: string): Promise { + const escrow = await this.prisma.escrow.findUnique({ where: { id } }); + if (!escrow || escrow.state !== 'SHIPPED' || escrow.deliveryRecordedAt !== null) { + return null; + } + const result = await this.prisma.escrow.update({ + where: { id }, + data: { deliveryRecordedAt: new Date() }, + }); + await this.invalidate(id); + return result; + } + + /** + * Releases the delivery claim by setting deliveryRecordedAt back to null, + * allowing the next poll cycle to retry. + */ + async clearDeliveryClaim(id: string): Promise { + const result = await this.prisma.escrow.update({ + where: { id }, + data: { deliveryRecordedAt: null }, + }); + await this.invalidate(id); + return result; + } + /** * Returns SHIPPED escrows whose deliveredAt is at or before the given * referenceTime and have no open dispute or existing auto-release transaction. diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index f0e6793a..88a7c633 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -505,6 +505,7 @@ export class PrismaService { > & { shippedAt?: { lte: Date }; deliveredAt?: { lte: Date } | null; + deliveryRecordedAt?: Date | null; createdAt?: { gte: Date; lte: Date }; }; select?: Partial>; @@ -546,6 +547,13 @@ export class PrismaService { return escrow.createdAt >= gte && escrow.createdAt <= lte; } + if ( + key === 'deliveryRecordedAt' && + (value === null || value === undefined) + ) { + return escrow.deliveryRecordedAt === value; + } + return escrow[key as keyof EscrowRecord] === value; }); }); @@ -671,10 +679,105 @@ export class PrismaService { | 'autoReleaseTxHash' | 'autoReleaseSubmittedAt' > - >; + > & { + deliveryRecordedAt?: Date | null; + }; } = {}): Promise => { return this.escrow.findMany({ where }).then((records) => records.length); }, + aggregate: ({ + _sum, + _avg, + _count, + }: { + _sum?: { amount?: boolean }; + _avg?: { amount?: boolean }; + _count?: { vendorAddress?: boolean; buyerAddress?: boolean }; + } = {}): Promise<{ + _sum?: { amount: number | null }; + _avg?: { amount: number | null }; + _count?: { vendorAddress: number; buyerAddress: number }; + }> => { + // Mirror the CANCELLED filter that escrow.findMany applies by default + const allEscrows = [...this.escrows.values()].filter( + (e) => e.state !== 'CANCELLED', + ); + + const result: Record = {}; + + if (_sum?.amount) { + const sum = allEscrows.reduce( + (s, e) => s + Number(e.amount), + 0, + ); + result._sum = { amount: sum }; + } + + if (_avg?.amount) { + const sum = allEscrows.reduce( + (s, e) => s + Number(e.amount), + 0, + ); + const avg = allEscrows.length > 0 ? sum / allEscrows.length : 0; + result._avg = { amount: avg }; + } + + if (_count?.vendorAddress) { + const unique = new Set(allEscrows.map((e) => e.vendorAddress)).size; + result._count = { ...(result._count as Record ?? {}), vendorAddress: unique }; + } + + if (_count?.buyerAddress) { + const unique = new Set(allEscrows.map((e) => e.buyerAddress)).size; + result._count = { ...(result._count as Record ?? {}), buyerAddress: unique }; + } + + return Promise.resolve(result as { + _sum?: { amount: number | null }; + _avg?: { amount: number | null }; + _count?: { vendorAddress: number; buyerAddress: number }; + }); + }, + groupBy: ({ + by, + _count, + }: { + by: string[]; + _count?: boolean; + }): Promise< + Array< + Record & { _count?: number } + > + > => { + // Mirror the CANCELLED filter that escrow.findMany applies by default + const allEscrows = [...this.escrows.values()].filter( + (e) => e.state !== 'CANCELLED', + ); + const groups = new Map(); + + for (const escrow of allEscrows) { + const groupKey = by.map((field) => escrow[field as keyof EscrowRecord]).join('|'); + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(escrow); + } + + const result: Array & { _count?: number }> = []; + for (const [groupKey, records] of groups.entries()) { + const group: Record = {}; + const keys = groupKey.split('|'); + for (let i = 0; i < by.length; i++) { + group[by[i]] = (records[0] as Record)[by[i]]; + } + if (_count) { + group._count = records.length; + } + result.push(group); + } + + return Promise.resolve(result); + }, deleteMany: (): Promise<{ count: number }> => { const count = this.escrows.size; this.escrows.clear(); @@ -727,10 +830,18 @@ export class PrismaService { }, findMany: ({ where, + orderBy, + skip, + take, }: { - where?: Partial>; + where?: Partial> & { + status?: DisputeState | { in?: DisputeState[] }; + }; + orderBy?: Partial>; + skip?: number; + take?: number; } = {}): Promise => { - const disputes = [...this.disputes.values()].filter((dispute) => { + let disputes = [...this.disputes.values()].filter((dispute) => { if (!where) { return true; } @@ -740,10 +851,54 @@ export class PrismaService { return true; } + // Support { in: [...] } for status filtering + if ( + key === 'status' && + typeof value === 'object' && + value !== null && + 'in' in value + ) { + return (value as { in: DisputeState[] }).in.includes( + dispute.status, + ); + } + return dispute[key as keyof DisputeRecord] === value; }); }); + if (orderBy) { + const [field, dir] = Object.entries(orderBy)[0] as [ + keyof DisputeRecord, + 'asc' | 'desc', + ]; + disputes = [...disputes].sort((a, b) => { + const aVal = a[field]; + const bVal = b[field]; + if (aVal instanceof Date && bVal instanceof Date) { + return dir === 'asc' + ? aVal.getTime() - bVal.getTime() + : bVal.getTime() - aVal.getTime(); + } + if (typeof aVal === 'number' && typeof bVal === 'number') { + return dir === 'asc' ? aVal - bVal : bVal - aVal; + } + if (typeof aVal === 'string' && typeof bVal === 'string') { + return dir === 'asc' + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal); + } + return 0; + }); + } + + if (skip !== undefined) { + disputes = disputes.slice(skip); + } + if (take !== undefined) { + disputes = disputes.slice(0, take); + } + return Promise.resolve(disputes.map((dispute) => ({ ...dispute }))); }, update: ({ @@ -771,6 +926,17 @@ export class PrismaService { .findMany({ where }) .then((records) => records[0] ?? null); }, + count: ({ + where, + }: { + where?: Partial> & { + status?: DisputeState | { in?: DisputeState[] }; + }; + } = {}): Promise => { + return this.dispute + .findMany({ where }) + .then((records) => records.length); + }, deleteMany: (): Promise<{ count: number }> => { const count = this.disputes.size; this.disputes.clear(); diff --git a/src/workers/tracking-poll.worker.ts b/src/workers/tracking-poll.worker.ts index 3c336fd1..1749f061 100644 --- a/src/workers/tracking-poll.worker.ts +++ b/src/workers/tracking-poll.worker.ts @@ -55,9 +55,29 @@ export class TrackingPollWorker implements OnModuleInit, OnApplicationShutdown { continue; } - const deliveredAt = new Date(); - await this.escrowRepository.markDelivered(escrow.id, deliveredAt); - await this.contractService.recordDelivery(escrow.id); + // Claim the escrow before any network call. This follows the same + // claim-and-release pattern as AutoReleaseWorker (#507): if the + // contract call fails, the claim is cleared so the next poll cycle + // retries. Without this, a failed recordDelivery leaves the escrow + // in DELIVERED state permanently out of sync with the chain. + const claimed = await this.escrowRepository.claimDelivery( + escrow.id, + ); + if (!claimed) { + continue; + } + + try { + await this.contractService.recordDelivery(escrow.id); + await this.escrowRepository.markDelivered( + escrow.id, + new Date(), + ); + } catch (error) { + // Release the claim so the next poll cycle can retry. + await this.escrowRepository.clearDeliveryClaim(escrow.id); + throw error; + } } 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..4a5e3992 100644 --- a/test/unit/api-keys.controller.spec.ts +++ b/test/unit/api-keys.controller.spec.ts @@ -1,96 +1,45 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; +import { RotateApiKeyDto } from '../../src/admin/api-keys/dto/rotate-api-key.dto'; import { ApiKeysController } from '../../src/admin/api-keys/api-keys.controller'; import { LogisticsService } from '../../src/logistics/logistics.service'; -import { JwtGuard } from '../../src/auth/guards/jwt.guard'; -import { AdminGuard } from '../../src/admin/guards/admin.guard'; -import { ConfigService } from '../../src/config/config.service'; - -describe('ApiKeysController (issue #410)', () => { - let app: INestApplication; - let logisticsService: any; - - const ADMIN = 'admin-address'; - - beforeEach(async () => { - logisticsService = { - getEncryptedApiKey: jest.fn(), - setApiKey: jest.fn(), - setEncryptedApiKey: jest.fn(), - }; - - const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [ApiKeysController], - providers: [ - { provide: LogisticsService, useValue: logisticsService }, - JwtGuard, - AdminGuard, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue(ADMIN) } }, - ], - }).compile(); - - app = moduleRef.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - it('returns 403 for a non-admin caller', async () => { - await request(app.getHttpServer()) - .patch('/admin/credentials/logistics') - .set('Authorization', '******') - .send({ key: 'does-not-matter' }) - .expect(403); - }); +describe('ApiKeysController (issue #410) — business logic', () => { + let logisticsService: LogisticsService; + let controller: ApiKeysController; - it('accepts admin and never leaks credential values in response', async () => { - // Ensure branch where there is no current encrypted key (first-time set) - logisticsService.getEncryptedApiKey.mockReturnValue(null); - logisticsService.setApiKey.mockImplementation((k: string) => { - // On set, pretend an encrypted value is now available - logisticsService.getEncryptedApiKey.mockReturnValue('enc:val:here'); - }); - - const res = await request(app.getHttpServer()) - .patch('/admin/credentials/logistics') - .set('Authorization', '******') - .send({ key: 'very-secret-key' }) - .expect(200); - - expect(res.body).toEqual({ message: 'Logistics API key updated and encrypted' }); - // ensure raw credential never echoed back - expect(JSON.stringify(res.body)).not.toContain('very-secret-key'); + function buildDto(key: string): RotateApiKeyDto { + const dto = new RotateApiKeyDto(); + dto.key = key; + return dto; + } - expect(logisticsService.setApiKey).toHaveBeenCalledWith('very-secret-key'); - expect(logisticsService.setEncryptedApiKey).toHaveBeenCalledWith('enc:val:here'); + beforeEach(() => { + logisticsService = new LogisticsService(); + controller = new ApiKeysController(logisticsService); }); - it('rotates existing encrypted key via reencryptCredential without leaking secrets', async () => { - // Provide an existing encrypted key; spy on reencryptCredential - const util = await import('../../src/common/sanitization/credential-encryption.util'); - const spy = jest.spyOn(util, 'reencryptCredential').mockReturnValue('reencrypted:val'); + it('accepts a key and never leaks credential values in response', async () => { + const result = await controller.rotateLogisticsKey(buildDto('very-secret-key')); - logisticsService.getEncryptedApiKey.mockReturnValue('old:enc:key'); + expect(result).toEqual({ message: 'Logistics API key updated and encrypted' }); + expect(JSON.stringify(result)).not.toContain('very-secret-key'); + expect(logisticsService.getApiKey()).toBe('very-secret-key'); + }); - const res = await request(app.getHttpServer()) - .patch('/admin/credentials/logistics') - .set('Authorization', '******') - .send({ key: 'should-not-be-used' }) - .expect(200); + it('rotates to the submitted key (not re-encrypting old one) and does not leak secrets', async () => { + // First set a key + await controller.rotateLogisticsKey(buildDto('old-key')); + const encryptedBefore = logisticsService.getEncryptedApiKey(); - expect(res.body).toEqual({ message: 'Logistics API key updated and encrypted' }); - expect(spy).toHaveBeenCalledWith('old:enc:key'); - expect(logisticsService.setEncryptedApiKey).toHaveBeenCalledWith('reencrypted:val'); - expect(JSON.stringify(res.body)).not.toContain('old:enc:key'); + const result = await controller.rotateLogisticsKey(buildDto('new-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'; + expect(result).toEqual({ message: 'Logistics API key updated and encrypted' }); + // The submitted key is used, not the old one re-encrypted + expect(logisticsService.getApiKey()).toBe('new-key'); + expect(logisticsService.getEncryptedApiKey()).not.toBe(encryptedBefore); + expect(JSON.stringify(result)).not.toContain('old-key'); + expect(JSON.stringify(result)).not.toContain('new-key'); + }); +}); 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..902a9d82 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 () => { @@ -281,356 +282,3 @@ describe('EscrowService.handleShipment (issue #16)', () => { }); }); }); -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', - 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', - itemRef: 'bag-123', - amount: 125, - currency: 'USDC', - buyerAddress: 'buyer-address', - }; - const createdEscrow: EscrowRecord = { - ...fundedEscrow, - id: 'escrow-2', - state: 'CREATED', - }; - 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).not.toHaveBeenCalled(); - }); - - 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(); - }); -}); diff --git a/test/unit/logistics.service.spec.ts b/test/unit/logistics.service.spec.ts index 46c0a3f1..cd3855a6 100644 --- a/test/unit/logistics.service.spec.ts +++ b/test/unit/logistics.service.spec.ts @@ -13,6 +13,17 @@ import { jest.mock('axios'); const mockedAxios = axios as jest.Mocked; +// jest.mock('axios') mocks the entire module, including axios.isAxiosError. +// The auto-mock returns undefined for isAxiosError, which would cause the +// GiglClient error handling (which relies on isAxiosError) to never trigger +// and just re-throw the original Error. We patch it to match the real check. +beforeAll(() => { + mockedAxios.isAxiosError.mockImplementation( + (err: any): err is import('axios').AxiosError => + typeof err === 'object' && err !== null && err.isAxiosError === true, + ); +}); + describe('LogisticsService & LogisticsModule (issue #479)', () => { let service: LogisticsService; let mockAxiosInstance: { get: jest.Mock }; diff --git a/test/unit/prisma.service.spec.ts b/test/unit/prisma.service.spec.ts index 45dd3f19..e1cb7308 100644 --- a/test/unit/prisma.service.spec.ts +++ b/test/unit/prisma.service.spec.ts @@ -1,4 +1,3 @@ -import { Test } from '@jest/globals'; import { PrismaService } from '../../src/prisma/prisma.service'; describe('PrismaService in-memory stores (issue #411)', () => { @@ -9,23 +8,25 @@ describe('PrismaService in-memory stores (issue #411)', () => { }); it('assertEncryptedContact throws when plaintext email or phone is written', async () => { - await expect( - prisma.escrow.create({ + // Wrap in async function because assertEncryptedContact throws synchronously, + // which bypasses .rejects matcher when called directly. + const tryCreateEmail = async () => { + await prisma.escrow.create({ data: { itemName: 'Item', amount: 10, currency: 'USDC', buyerAddress: 'buyer', vendorAddress: 'vendor', - // plaintext should be rejected buyerContactEmail: 'plain@example.com', }, - }), - ).rejects.toThrow(/must be encrypted/); + }); + }; + await expect(tryCreateEmail()).rejects.toThrow(/must be encrypted/); // phone plaintext - await expect( - prisma.escrow.create({ + const tryCreatePhone = async () => { + await prisma.escrow.create({ data: { itemName: 'Item', amount: 10, @@ -34,8 +35,9 @@ describe('PrismaService in-memory stores (issue #411)', () => { vendorAddress: 'vendor', buyerContactPhone: '+1234567890', }, - }), - ).rejects.toThrow(/must be encrypted/); + }); + }; + await expect(tryCreatePhone()).rejects.toThrow(/must be encrypted/); }); it('create/findUnique/findMany/update for escrow and updateMany behavior', async () => { diff --git a/test/unit/tracking-poll.worker.intervals.spec.ts b/test/unit/tracking-poll.worker.intervals.spec.ts index f8898fc7..82c5b78e 100644 --- a/test/unit/tracking-poll.worker.intervals.spec.ts +++ b/test/unit/tracking-poll.worker.intervals.spec.ts @@ -43,7 +43,9 @@ describe('TrackingPollWorker — interval scheduling & polling loop (issue #46)' beforeEach(async () => { escrowRepository = { findShippedWithTracking: jest.fn(), + claimDelivery: jest.fn().mockResolvedValue(true), markDelivered: jest.fn(), + clearDeliveryClaim: jest.fn(), } as unknown as jest.Mocked; logisticsService = { getStatus: jest.fn(), diff --git a/test/unit/tracking-poll.worker.spec.ts b/test/unit/tracking-poll.worker.spec.ts index 69aed25a..1451ae6b 100644 --- a/test/unit/tracking-poll.worker.spec.ts +++ b/test/unit/tracking-poll.worker.spec.ts @@ -14,6 +14,8 @@ describe('TrackingPollWorker (issue #11)', () => { escrowRepository = { findShippedWithTracking: jest.fn(), markDelivered: jest.fn(), + claimDelivery: jest.fn(), + clearDeliveryClaim: jest.fn(), } as unknown as jest.Mocked; logisticsService = { getStatus: jest.fn(), @@ -35,26 +37,30 @@ describe('TrackingPollWorker (issue #11)', () => { }); it('marks delivered escrows and records the delivery contract call', async () => { - escrowRepository.findShippedWithTracking.mockResolvedValue([ - { - id: 'escrow-1', - itemName: 'Camera', - amount: 250, - currency: 'USDC', - itemRef: 'ref-1', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-1', - deliveredAt: null, - deliveryRecordedAt: null, - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); + const escrow = { + id: 'escrow-1', + itemName: 'Camera', + amount: 250, + currency: 'USDC', + itemRef: 'ref-1', + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + state: 'SHIPPED', + trackingId: 'TRK-1', + deliveredAt: null, + deliveryRecordedAt: null, + autoReleaseSubmittedAt: null, + autoReleaseTxHash: null, + disputeId: null, + createdAt: new Date(), + updatedAt: new Date(), + cancelledAt: null, + shippedAt: null, + buyerContactEmail: null, + buyerContactPhone: null, + }; + escrowRepository.findShippedWithTracking.mockResolvedValue([escrow]); + escrowRepository.claimDelivery.mockResolvedValue(escrow); logisticsService.getStatus.mockResolvedValue({ status: 'DELIVERED', events: [], @@ -63,34 +69,81 @@ describe('TrackingPollWorker (issue #11)', () => { await worker.run(); + expect(escrowRepository.claimDelivery).toHaveBeenCalledWith('escrow-1'); + expect(contractService.recordDelivery).toHaveBeenCalledWith('escrow-1'); expect(escrowRepository.markDelivered).toHaveBeenCalledWith( 'escrow-1', expect.any(Date), ); - expect(contractService.recordDelivery).toHaveBeenCalledWith('escrow-1'); + }); + + it('clears the delivery claim when contract call fails, leaving escrow retryable', async () => { + const escrow = { + id: 'escrow-1', + itemName: 'Camera', + amount: 250, + currency: 'USDC', + itemRef: 'ref-1', + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + state: 'SHIPPED', + trackingId: 'TRK-1', + deliveredAt: null, + deliveryRecordedAt: null, + autoReleaseSubmittedAt: null, + autoReleaseTxHash: null, + disputeId: null, + createdAt: new Date(), + updatedAt: new Date(), + cancelledAt: null, + shippedAt: null, + buyerContactEmail: null, + buyerContactPhone: null, + }; + escrowRepository.findShippedWithTracking.mockResolvedValue([escrow]); + escrowRepository.claimDelivery.mockResolvedValue(escrow); + logisticsService.getStatus.mockResolvedValue({ + status: 'DELIVERED', + events: [], + }); + contractService.recordDelivery.mockRejectedValue( + new Error('contract timeout'), + ); + + await expect(worker.run()).resolves.toBeUndefined(); + + // The claim must be cleared so the next poll cycle retries + expect(escrowRepository.clearDeliveryClaim).toHaveBeenCalledWith( + 'escrow-1', + ); + // The escrow should NOT be marked delivered since the contract call failed + expect(escrowRepository.markDelivered).not.toHaveBeenCalled(); }); it('keeps polling resilient to carrier API failures', async () => { - escrowRepository.findShippedWithTracking.mockResolvedValue([ - { - id: 'escrow-1', - itemName: 'Camera', - amount: 250, - currency: 'USDC', - itemRef: 'ref-1', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-1', - deliveredAt: null, - deliveryRecordedAt: null, - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); + const escrow = { + id: 'escrow-1', + itemName: 'Camera', + amount: 250, + currency: 'USDC', + itemRef: 'ref-1', + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + state: 'SHIPPED', + trackingId: 'TRK-1', + deliveredAt: null, + deliveryRecordedAt: null, + autoReleaseSubmittedAt: null, + autoReleaseTxHash: null, + disputeId: null, + createdAt: new Date(), + updatedAt: new Date(), + cancelledAt: null, + shippedAt: null, + buyerContactEmail: null, + buyerContactPhone: null, + }; + escrowRepository.findShippedWithTracking.mockResolvedValue([escrow]); logisticsService.getStatus.mockRejectedValue(new Error('carrier down')); await expect(worker.run()).resolves.toBeUndefined();