import { BadRequestException, ConflictException } from '@nestjs/common'; import { ContractTransitionService } from './contract-transition.service'; /** * Signing is one-shot. The single exception: a contract signed before company * stamps were required must be re-signable so the customer can attach one — * otherwise counterSign's both-stamps gate strands it forever. These specs pin * that exception open and pin everything else shut. */ describe('customer re-sign to attach a missing stamp', () => { const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' }; const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' }; const build = (contract: unknown, existingSignature: unknown) => { const applied: unknown[] = []; const service = Object.create( ContractTransitionService.prototype, ) as ContractTransitionService; Object.assign(service, { contractsService: { findById: jest.fn().mockResolvedValue(contract), assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined), }, contractsRepository: { findSignature: jest.fn().mockResolvedValue(existingSignature), update: jest.fn().mockResolvedValue(undefined), }, otpService: { verifyOtpForAction: jest.fn().mockResolvedValue(undefined), sendOtp: jest.fn().mockResolvedValue(undefined), }, notifier: { customerSignedToStaff: jest.fn() }, resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }), applySignature: jest.fn((...args: unknown[]) => { applied.push(args); return Promise.resolve(); }), regenerateContractPdf: jest.fn().mockResolvedValue(undefined), }); return { service, applied }; }; const dto = { role: 'CUSTOMER' as const, signerDisplayName: 'C. Customer', signatureImageBase64: 'data:image/png;base64,AAAA', stampImageBase64: 'data:image/png;base64,BBBB', otp: '123456', }; it('lets a customer sign again when their signature has no stamp', async () => { const { service, applied } = build(signedNoStamp, { id: 's-1', role: 'CUSTOMER', stampFileId: null, }); await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); expect(applied).toHaveLength(1); }); it('still refuses a second signature once a stamp is on file', async () => { const { service } = build(signedNoStamp, { id: 's-1', role: 'CUSTOMER', stampFileId: 'file-1', }); // Stamped already → not the re-sign case, so the status guard rejects // SIGNED_CUSTOMER before the already-signed check is reached. await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf( ConflictException, ); }); it('refuses a second signature on a still-ready contract', async () => { const { service } = build(contractReady, { id: 's-1', role: 'CUSTOMER', stampFileId: 'file-1', }); await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow( /already signed/i, ); }); it('signs normally when nothing is on file yet', async () => { const { service, applied } = build(contractReady, null); await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); expect(applied).toHaveLength(1); }); it('sends a signing OTP for the stamp re-sign', async () => { const { service } = build(signedNoStamp, { id: 's-1', role: 'CUSTOMER', stampFileId: null, }); await expect( service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), ).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) })); }); it('refuses a signing OTP once the contract is signed and stamped', async () => { const { service } = build(signedNoStamp, { id: 's-1', role: 'CUSTOMER', stampFileId: 'file-1', }); await expect( service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), ).rejects.toBeInstanceOf(ConflictException); }); it('requires the OTP on the re-sign path too', async () => { const { service } = build(signedNoStamp, { id: 's-1', role: 'CUSTOMER', stampFileId: null, }); await expect( service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }), ).rejects.toBeInstanceOf(BadRequestException); }); });