Le statut PENDING_PAYMENT ne designe plus un paiement en attente mais un devis : une reservation construite dont les frais de booking ne sont pas encore regles. L'enum backend devient QUOTE, converti en place par migration. Les deux onglets se partagent desormais la meme liste : - « Reservations » n'affiche que PENDING, ACCEPTED et REJECTED ; - « Historique des devis » regroupe QUOTE, PENDING_BANK_TRANSFER et CANCELLED, avec les actions propres a un devis (modifier, payer, supprimer). La table, l'export et les filtres sont extraits dans BookingListView pour eviter de dupliquer la page entre les deux vues. Tableau de bord : - l'alerte « Paiement en attente » disparait des points a traiter, les devis non regles ne bloquant aucun envoi en cours ; - « Sans reponse du transporteur » devient « En attente du transporteur ». Migration verifiee en transaction annulee sur la base de dev : les lignes PENDING_PAYMENT deviennent QUOTE, l'enum et le defaut de colonne suivent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm
545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
import {
|
|
CsvBooking,
|
|
CsvBookingStatus,
|
|
DocumentType,
|
|
CsvBookingDocument,
|
|
} from './csv-booking.entity';
|
|
import { PortCode } from '../value-objects/port-code.vo';
|
|
|
|
describe('CsvBooking Entity', () => {
|
|
// Test data factory
|
|
const createValidBooking = (
|
|
_overrides?: Partial<ConstructorParameters<typeof CsvBooking>[0]>
|
|
): CsvBooking => {
|
|
const documents: CsvBookingDocument[] = [
|
|
{
|
|
id: 'doc-1',
|
|
type: DocumentType.BILL_OF_LADING,
|
|
fileName: 'bill-of-lading.pdf',
|
|
filePath: '/uploads/bill-of-lading.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 1024,
|
|
uploadedAt: new Date(),
|
|
},
|
|
{
|
|
id: 'doc-2',
|
|
type: DocumentType.PACKING_LIST,
|
|
fileName: 'packing-list.pdf',
|
|
filePath: '/uploads/packing-list.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 2048,
|
|
uploadedAt: new Date(),
|
|
},
|
|
{
|
|
id: 'doc-3',
|
|
type: DocumentType.COMMERCIAL_INVOICE,
|
|
fileName: 'invoice.pdf',
|
|
filePath: '/uploads/invoice.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 3072,
|
|
uploadedAt: new Date(),
|
|
},
|
|
];
|
|
|
|
return new CsvBooking(
|
|
'booking-123',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'bookings@sscconsolidation.com',
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
10.5,
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
documents,
|
|
'token-abc123',
|
|
new Date(),
|
|
undefined,
|
|
'Test booking',
|
|
undefined
|
|
);
|
|
};
|
|
|
|
describe('Constructor and Validation', () => {
|
|
it('should create a valid booking', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.id).toBe('booking-123');
|
|
expect(booking.userId).toBe('user-456');
|
|
expect(booking.organizationId).toBe('org-789');
|
|
expect(booking.carrierName).toBe('SSC Consolidation');
|
|
expect(booking.carrierEmail).toBe('bookings@sscconsolidation.com');
|
|
expect(booking.status).toBe(CsvBookingStatus.PENDING);
|
|
expect(booking.documents).toHaveLength(3);
|
|
});
|
|
|
|
it('should throw error if ID is empty', () => {
|
|
expect(() => {
|
|
const docs: CsvBookingDocument[] = [
|
|
{
|
|
id: 'doc-1',
|
|
type: DocumentType.BILL_OF_LADING,
|
|
fileName: 'test.pdf',
|
|
filePath: '/test.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 1024,
|
|
uploadedAt: new Date(),
|
|
},
|
|
];
|
|
new CsvBooking(
|
|
'',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'bookings@sscconsolidation.com',
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
10.5,
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
docs,
|
|
'token-abc123',
|
|
new Date()
|
|
);
|
|
}).toThrow('Booking ID is required');
|
|
});
|
|
|
|
it('should throw error if volume is negative', () => {
|
|
expect(() => {
|
|
const docs: CsvBookingDocument[] = [
|
|
{
|
|
id: 'doc-1',
|
|
type: DocumentType.BILL_OF_LADING,
|
|
fileName: 'test.pdf',
|
|
filePath: '/test.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 1024,
|
|
uploadedAt: new Date(),
|
|
},
|
|
];
|
|
new CsvBooking(
|
|
'booking-123',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'bookings@sscconsolidation.com',
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
-10.5, // Negative volume
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
docs,
|
|
'token-abc123',
|
|
new Date()
|
|
);
|
|
}).toThrow('Volume must be positive');
|
|
});
|
|
|
|
it('should throw error if email format is invalid', () => {
|
|
expect(() => {
|
|
const docs: CsvBookingDocument[] = [
|
|
{
|
|
id: 'doc-1',
|
|
type: DocumentType.BILL_OF_LADING,
|
|
fileName: 'test.pdf',
|
|
filePath: '/test.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 1024,
|
|
uploadedAt: new Date(),
|
|
},
|
|
];
|
|
new CsvBooking(
|
|
'booking-123',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'invalid-email', // Invalid email
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
10.5,
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
docs,
|
|
'token-abc123',
|
|
new Date()
|
|
);
|
|
}).toThrow('Invalid carrier email format');
|
|
});
|
|
|
|
it('should allow reconstituting a booking with no documents (all deleted before payment)', () => {
|
|
// A booking may end up with zero documents after the owner deletes them.
|
|
// Reconstitution must not fail (creation still requires >=1 doc at the service level).
|
|
expect(() => {
|
|
new CsvBooking(
|
|
'booking-123',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'bookings@sscconsolidation.com',
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
10.5,
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
[], // Empty documents
|
|
'token-abc123',
|
|
new Date()
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Accept Method', () => {
|
|
it('should accept a pending booking', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.PENDING);
|
|
|
|
booking.accept();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.ACCEPTED);
|
|
expect(booking.respondedAt).toBeDefined();
|
|
});
|
|
|
|
it('should throw error when accepting non-pending booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.accept(); // First acceptance
|
|
|
|
expect(() => {
|
|
booking.accept(); // Try to accept again
|
|
}).toThrow('Cannot accept booking with status ACCEPTED');
|
|
});
|
|
|
|
it('should throw error when accepting expired booking', () => {
|
|
const booking = createValidBooking();
|
|
// Set requestedAt to 8 days ago (expired)
|
|
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
|
|
|
expect(booking.isExpired()).toBe(true);
|
|
|
|
expect(() => {
|
|
booking.accept();
|
|
}).toThrow('Cannot accept expired booking');
|
|
});
|
|
});
|
|
|
|
describe('Reject Method', () => {
|
|
it('should reject a pending booking', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.PENDING);
|
|
|
|
booking.reject('Capacity full');
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.REJECTED);
|
|
expect(booking.respondedAt).toBeDefined();
|
|
expect(booking.rejectionReason).toBe('Capacity full');
|
|
});
|
|
|
|
it('should reject without reason', () => {
|
|
const booking = createValidBooking();
|
|
|
|
booking.reject();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.REJECTED);
|
|
expect(booking.rejectionReason).toBeUndefined();
|
|
});
|
|
|
|
it('should throw error when rejecting non-pending booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.reject(); // First rejection
|
|
|
|
expect(() => {
|
|
booking.reject(); // Try to reject again
|
|
}).toThrow('Cannot reject booking with status REJECTED');
|
|
});
|
|
});
|
|
|
|
describe('Cancel Method', () => {
|
|
it('should cancel a pending booking', () => {
|
|
const booking = createValidBooking();
|
|
|
|
booking.cancel();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.CANCELLED);
|
|
expect(booking.respondedAt).toBeDefined();
|
|
});
|
|
|
|
it('should throw error when cancelling accepted booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.accept();
|
|
|
|
expect(() => {
|
|
booking.cancel();
|
|
}).toThrow('Cannot cancel accepted booking');
|
|
});
|
|
|
|
it('should throw error when cancelling rejected booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.reject();
|
|
|
|
expect(() => {
|
|
booking.cancel();
|
|
}).toThrow('Cannot cancel rejected booking');
|
|
});
|
|
});
|
|
|
|
describe('isDeletable', () => {
|
|
it('allows deleting a booking whose commission is still unpaid', () => {
|
|
const booking = createValidBooking();
|
|
booking.status = CsvBookingStatus.QUOTE;
|
|
|
|
expect(booking.isDeletable()).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
// Paye : la reservation est partie chez le transporteur.
|
|
CsvBookingStatus.PENDING,
|
|
CsvBookingStatus.ACCEPTED,
|
|
CsvBookingStatus.REJECTED,
|
|
CsvBookingStatus.CANCELLED,
|
|
// Virement declare : il peut etre en cours d'acheminement.
|
|
CsvBookingStatus.PENDING_BANK_TRANSFER,
|
|
])('refuses to delete a %s booking', status => {
|
|
const booking = createValidBooking();
|
|
booking.status = status;
|
|
|
|
expect(booking.isDeletable()).toBe(false);
|
|
});
|
|
|
|
it('stops being deletable once the payment is completed', () => {
|
|
const booking = createValidBooking();
|
|
booking.status = CsvBookingStatus.QUOTE;
|
|
expect(booking.isDeletable()).toBe(true);
|
|
|
|
booking.markPaymentCompleted();
|
|
|
|
expect(booking.status).toBe(CsvBookingStatus.PENDING);
|
|
expect(booking.isDeletable()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Expiration Logic', () => {
|
|
it('should not be expired for recent bookings', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.isExpired()).toBe(false);
|
|
});
|
|
|
|
it('should be expired after 7 days', () => {
|
|
const booking = createValidBooking();
|
|
// Set requestedAt to 8 days ago
|
|
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
|
|
|
expect(booking.isExpired()).toBe(true);
|
|
});
|
|
|
|
it('should not be expired if already accepted', () => {
|
|
const booking = createValidBooking();
|
|
booking.accept();
|
|
|
|
// Set requestedAt to 8 days ago
|
|
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
|
|
|
expect(booking.isExpired()).toBe(false);
|
|
});
|
|
|
|
it('should calculate days until expiration correctly', () => {
|
|
const booking = createValidBooking();
|
|
|
|
const days = booking.getDaysUntilExpiration();
|
|
|
|
expect(days).toBeGreaterThan(6);
|
|
expect(days).toBeLessThanOrEqual(7);
|
|
});
|
|
|
|
it('should return 0 days for accepted bookings', () => {
|
|
const booking = createValidBooking();
|
|
booking.accept();
|
|
|
|
expect(booking.getDaysUntilExpiration()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Status Check Methods', () => {
|
|
it('should correctly identify pending booking', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.isPending()).toBe(true);
|
|
expect(booking.isAccepted()).toBe(false);
|
|
expect(booking.isRejected()).toBe(false);
|
|
expect(booking.isCancelled()).toBe(false);
|
|
});
|
|
|
|
it('should correctly identify accepted booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.accept();
|
|
|
|
expect(booking.isPending()).toBe(false);
|
|
expect(booking.isAccepted()).toBe(true);
|
|
expect(booking.isRejected()).toBe(false);
|
|
expect(booking.isCancelled()).toBe(false);
|
|
});
|
|
|
|
it('should correctly identify rejected booking', () => {
|
|
const booking = createValidBooking();
|
|
booking.reject();
|
|
|
|
expect(booking.isPending()).toBe(false);
|
|
expect(booking.isAccepted()).toBe(false);
|
|
expect(booking.isRejected()).toBe(true);
|
|
expect(booking.isCancelled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Document Methods', () => {
|
|
it('should check if document type exists', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.hasDocumentType(DocumentType.BILL_OF_LADING)).toBe(true);
|
|
expect(booking.hasDocumentType(DocumentType.CERTIFICATE_OF_ORIGIN)).toBe(false);
|
|
});
|
|
|
|
it('should get documents by type', () => {
|
|
const booking = createValidBooking();
|
|
|
|
const billOfLading = booking.getDocumentsByType(DocumentType.BILL_OF_LADING);
|
|
expect(billOfLading).toHaveLength(1);
|
|
expect(billOfLading[0].fileName).toBe('bill-of-lading.pdf');
|
|
});
|
|
|
|
it('should check if all required documents are present', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.hasAllRequiredDocuments()).toBe(true);
|
|
});
|
|
|
|
it('should return false if required documents are missing', () => {
|
|
const docs: CsvBookingDocument[] = [
|
|
{
|
|
id: 'doc-1',
|
|
type: DocumentType.BILL_OF_LADING,
|
|
fileName: 'bill-of-lading.pdf',
|
|
filePath: '/uploads/bill-of-lading.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 1024,
|
|
uploadedAt: new Date(),
|
|
},
|
|
];
|
|
|
|
const booking = new CsvBooking(
|
|
'booking-123',
|
|
'user-456',
|
|
'org-789',
|
|
'SSC Consolidation',
|
|
'bookings@sscconsolidation.com',
|
|
PortCode.create('NLRTM'),
|
|
PortCode.create('USNYC'),
|
|
10.5,
|
|
1500,
|
|
3,
|
|
1200.0,
|
|
1100.0,
|
|
'USD',
|
|
15,
|
|
'LCL',
|
|
CsvBookingStatus.PENDING,
|
|
docs,
|
|
'token-abc123',
|
|
new Date()
|
|
);
|
|
|
|
expect(booking.hasAllRequiredDocuments()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Helper Methods', () => {
|
|
it('should return route description', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.getRouteDescription()).toBe('NLRTM → USNYC');
|
|
});
|
|
|
|
it('should return booking summary', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.getSummary()).toContain('CSV Booking booking-123');
|
|
expect(booking.getSummary()).toContain('SSC Consolidation');
|
|
expect(booking.getSummary()).toContain('NLRTM → USNYC');
|
|
expect(booking.getSummary()).toContain('PENDING');
|
|
});
|
|
|
|
it('should return price in specified currency', () => {
|
|
const booking = createValidBooking();
|
|
|
|
expect(booking.getPriceInCurrency('USD')).toBe(1200.0);
|
|
expect(booking.getPriceInCurrency('EUR')).toBe(1100.0);
|
|
});
|
|
|
|
it('should calculate response time in hours', () => {
|
|
const booking = createValidBooking();
|
|
|
|
// No response yet
|
|
expect(booking.getResponseTimeHours()).toBeNull();
|
|
|
|
// Accept booking
|
|
booking.accept();
|
|
|
|
const responseTime = booking.getResponseTimeHours();
|
|
expect(responseTime).toBeGreaterThanOrEqual(0);
|
|
expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test
|
|
});
|
|
});
|
|
|
|
describe('applyBookingFee', () => {
|
|
it('should set a flat per-booking fee and clear the percentage rate', () => {
|
|
const booking = createValidBooking();
|
|
|
|
booking.applyBookingFee(10);
|
|
|
|
expect(booking.commissionAmountEur).toBe(10);
|
|
expect(booking.commissionRate).toBeUndefined();
|
|
});
|
|
|
|
it('should store no fee when the plan fee is custom or zero', () => {
|
|
const booking = createValidBooking();
|
|
|
|
booking.applyBookingFee(-1);
|
|
|
|
expect(booking.commissionAmountEur).toBe(0);
|
|
expect(booking.commissionRate).toBeUndefined();
|
|
});
|
|
});
|
|
});
|