// Mock dependencies const mockRentalFindByPk = jest.fn(); const mockRentalUpdate = jest.fn(); const mockCreateRefund = jest.fn(); jest.mock('../../../models', () => ({ Rental: { findByPk: mockRentalFindByPk } })); jest.mock('../../../services/stripeService', () => ({ createRefund: mockCreateRefund })); const mockLoggerError = jest.fn(); const mockLoggerWarn = jest.fn(); jest.mock('../../../utils/logger', () => ({ error: mockLoggerError, warn: mockLoggerWarn, info: jest.fn(), withRequestId: jest.fn(() => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), })), })); const RefundService = require('../../../services/refundService'); describe('RefundService', () => { let consoleSpy, consoleErrorSpy, consoleWarnSpy; beforeEach(() => { jest.clearAllMocks(); // Set up console spies consoleSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); }); afterEach(() => { consoleSpy.mockRestore(); consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); describe('calculateRefundAmount', () => { const baseRental = { totalAmount: 100.00, startDateTime: new Date('2023-12-01T10:00:00Z') }; describe('Owner cancellation', () => { it('should return 100% refund when cancelled by owner', () => { const result = RefundService.calculateRefundAmount(baseRental, 'owner'); expect(result).toEqual({ refundAmount: 100.00, refundPercentage: 1.0, reason: 'Full refund - cancelled by owner' }); }); it('should handle decimal amounts correctly for owner cancellation', () => { const rental = { ...baseRental, totalAmount: 125.75 }; const result = RefundService.calculateRefundAmount(rental, 'owner'); expect(result).toEqual({ refundAmount: 125.75, refundPercentage: 1.0, reason: 'Full refund - cancelled by owner' }); }); }); describe('Renter cancellation', () => { it('should return 0% refund when cancelled within 24 hours', () => { // Use fake timers to set the current time jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-30T15:00:00Z')); // 19 hours before start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 0.00, refundPercentage: 0.0, reason: 'No refund - cancelled within 24 hours of start time' }); jest.useRealTimers(); }); it('should return 50% refund when cancelled between 24-48 hours', () => { // Use fake timers to set the current time jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 50.00, refundPercentage: 0.5, reason: '50% refund - cancelled between 24-48 hours of start time' }); jest.useRealTimers(); }); it('should return 100% refund when cancelled more than 48 hours before', () => { // Use fake timers to set the current time jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-28T15:00:00Z')); // 67 hours before start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 100.00, refundPercentage: 1.0, reason: 'Full refund - cancelled more than 48 hours before start time' }); jest.useRealTimers(); }); it('should handle decimal calculations correctly for 50% refund', () => { const rental = { ...baseRental, totalAmount: 127.33 }; jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T15:00:00Z')); // 43 hours before start const result = RefundService.calculateRefundAmount(rental, 'renter'); expect(result).toEqual({ refundAmount: 63.66, // 127.33 * 0.5 = 63.665, rounded to 63.66 refundPercentage: 0.5, reason: '50% refund - cancelled between 24-48 hours of start time' }); jest.useRealTimers(); }); it('should handle edge case exactly at 24 hours', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-30T10:00:00Z')); // exactly 24 hours before start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 50.00, refundPercentage: 0.5, reason: '50% refund - cancelled between 24-48 hours of start time' }); jest.useRealTimers(); }); it('should handle edge case exactly at 48 hours', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T10:00:00Z')); // exactly 48 hours before start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 100.00, refundPercentage: 1.0, reason: 'Full refund - cancelled more than 48 hours before start time' }); jest.useRealTimers(); }); }); describe('Edge cases', () => { it('should handle zero total amount', () => { const rental = { ...baseRental, totalAmount: 0 }; const result = RefundService.calculateRefundAmount(rental, 'owner'); expect(result).toEqual({ refundAmount: 0.00, refundPercentage: 1.0, reason: 'Full refund - cancelled by owner' }); }); it('should handle unknown cancelledBy value', () => { const result = RefundService.calculateRefundAmount(baseRental, 'unknown'); expect(result).toEqual({ refundAmount: 0.00, refundPercentage: 0, reason: '' }); }); it('should handle past rental start time for renter cancellation', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2023-12-02T10:00:00Z')); // 24 hours after start const result = RefundService.calculateRefundAmount(baseRental, 'renter'); expect(result).toEqual({ refundAmount: 0.00, refundPercentage: 0.0, reason: 'No refund - cancelled within 24 hours of start time' }); jest.useRealTimers(); }); }); }); describe('validateCancellationEligibility', () => { const baseRental = { id: 1, renterId: 100, ownerId: 200, status: 'pending', paymentStatus: 'paid' }; describe('Status validation', () => { it('should reject cancellation for already cancelled rental', () => { const rental = { ...baseRental, status: 'cancelled' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: false, reason: 'Rental is already cancelled', cancelledBy: null }); }); it('should reject cancellation for completed rental', () => { const rental = { ...baseRental, status: 'completed' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: false, reason: 'Cannot cancel completed rental', cancelledBy: null }); }); it('should reject cancellation for active rental (computed from confirmed + past start)', () => { // Active status is now computed: confirmed + startDateTime in the past const pastDate = new Date(); pastDate.setHours(pastDate.getHours() - 1); // 1 hour ago const rental = { ...baseRental, status: 'confirmed', startDateTime: pastDate }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: false, reason: 'Cannot cancel active rental', cancelledBy: null }); }); }); describe('Authorization validation', () => { it('should allow renter to cancel', () => { const result = RefundService.validateCancellationEligibility(baseRental, 100); expect(result).toEqual({ canCancel: true, reason: 'Cancellation allowed', cancelledBy: 'renter' }); }); it('should allow owner to cancel', () => { const result = RefundService.validateCancellationEligibility(baseRental, 200); expect(result).toEqual({ canCancel: true, reason: 'Cancellation allowed', cancelledBy: 'owner' }); }); it('should reject unauthorized user', () => { const result = RefundService.validateCancellationEligibility(baseRental, 999); expect(result).toEqual({ canCancel: false, reason: 'You are not authorized to cancel this rental', cancelledBy: null }); }); }); describe('Payment status validation', () => { it('should reject cancellation for confirmed rental with unpaid status', () => { // Confirmed rentals require payment to be settled const rental = { ...baseRental, status: 'confirmed', paymentStatus: 'pending' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: false, reason: 'Cannot cancel rental that hasn\'t been paid', cancelledBy: null }); }); it('should reject cancellation for confirmed rental with failed payment', () => { const rental = { ...baseRental, status: 'confirmed', paymentStatus: 'failed' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: false, reason: 'Cannot cancel rental that hasn\'t been paid', cancelledBy: null }); }); it('should allow cancellation for free rental with not_required payment status', () => { const rental = { ...baseRental, paymentStatus: 'not_required' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: true, reason: 'Cancellation allowed', cancelledBy: 'renter' }); }); }); describe('Pending rental cancellation (before owner approval)', () => { it('should allow renter to cancel pending rental even with pending payment', () => { // Pending rentals can be cancelled before owner approval, no payment processed yet const rental = { ...baseRental, status: 'pending', paymentStatus: 'pending' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ canCancel: true, reason: 'Cancellation allowed', cancelledBy: 'renter' }); }); it('should allow owner to cancel pending rental', () => { const rental = { ...baseRental, status: 'pending', paymentStatus: 'pending' }; const result = RefundService.validateCancellationEligibility(rental, 200); expect(result).toEqual({ canCancel: true, reason: 'Cancellation allowed', cancelledBy: 'owner' }); }); }); describe('Edge cases', () => { it('should handle string user IDs that don\'t match', () => { const result = RefundService.validateCancellationEligibility(baseRental, '100'); expect(result).toEqual({ canCancel: false, reason: 'You are not authorized to cancel this rental', cancelledBy: null }); }); it('should handle null user ID', () => { const result = RefundService.validateCancellationEligibility(baseRental, null); expect(result).toEqual({ canCancel: false, reason: 'You are not authorized to cancel this rental', cancelledBy: null }); }); }); }); describe('processCancellation', () => { let mockRental; beforeEach(() => { mockRental = { id: 1, renterId: 100, ownerId: 200, status: 'pending', paymentStatus: 'paid', totalAmount: 100.00, stripePaymentIntentId: 'pi_123456789', startDateTime: new Date('2023-12-01T10:00:00Z'), update: mockRentalUpdate }; mockRentalFindByPk.mockResolvedValue(mockRental); mockRentalUpdate.mockResolvedValue(mockRental); }); describe('Rental not found', () => { it('should throw error when rental not found', async () => { mockRentalFindByPk.mockResolvedValue(null); await expect(RefundService.processCancellation('999', 100)) .rejects.toThrow('Rental not found'); expect(mockRentalFindByPk).toHaveBeenCalledWith('999'); }); }); describe('Validation failures', () => { it('should throw error for invalid cancellation', async () => { mockRental.status = 'cancelled'; await expect(RefundService.processCancellation(1, 100)) .rejects.toThrow('Rental is already cancelled'); }); it('should throw error for unauthorized user', async () => { await expect(RefundService.processCancellation(1, 999)) .rejects.toThrow('You are not authorized to cancel this rental'); }); }); describe('Successful cancellation with refund', () => { beforeEach(() => { // Set time to more than 48 hours before start for full refund jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-28T10:00:00Z')); mockCreateRefund.mockResolvedValue({ id: 're_123456789', amount: 10000 // Stripe uses cents }); }); afterEach(() => { jest.useRealTimers(); }); it('should process owner cancellation with full refund', async () => { const result = await RefundService.processCancellation(1, 200, 'Owner needs to cancel'); // Verify Stripe refund was created expect(mockCreateRefund).toHaveBeenCalledWith({ paymentIntentId: 'pi_123456789', amount: 100.00, metadata: { rentalId: 1, cancelledBy: 'owner', refundReason: 'Full refund - cancelled by owner' } }); // Verify rental was updated expect(mockRentalUpdate).toHaveBeenCalledWith({ status: 'cancelled', cancelledBy: 'owner', cancelledAt: expect.any(Date), refundAmount: 100.00, refundProcessedAt: expect.any(Date), refundReason: 'Owner needs to cancel', stripeRefundId: 're_123456789', payoutStatus: 'pending' }); expect(result).toEqual({ rental: mockRental, refund: { amount: 100.00, percentage: 1.0, reason: 'Full refund - cancelled by owner', processed: true, stripeRefundId: 're_123456789' } }); }); it('should process renter cancellation with partial refund', async () => { // Set time to 36 hours before start for 50% refund jest.useRealTimers(); // Reset timers first jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start mockCreateRefund.mockResolvedValue({ id: 're_partial', amount: 5000 // 50% in cents }); const result = await RefundService.processCancellation(1, 100); expect(mockCreateRefund).toHaveBeenCalledWith({ paymentIntentId: 'pi_123456789', amount: 50.00, metadata: { rentalId: 1, cancelledBy: 'renter', refundReason: '50% refund - cancelled between 24-48 hours of start time' } }); expect(result.refund).toEqual({ amount: 50.00, percentage: 0.5, reason: '50% refund - cancelled between 24-48 hours of start time', processed: true, stripeRefundId: 're_partial' }); }); }); describe('No refund scenarios', () => { beforeEach(() => { // Set time to within 24 hours for no refund jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-30T15:00:00Z')); }); afterEach(() => { jest.useRealTimers(); }); it('should handle cancellation with no refund', async () => { const result = await RefundService.processCancellation(1, 100); // Verify no Stripe refund was attempted expect(mockCreateRefund).not.toHaveBeenCalled(); // Verify rental was updated expect(mockRentalUpdate).toHaveBeenCalledWith({ status: 'cancelled', cancelledBy: 'renter', cancelledAt: expect.any(Date), refundAmount: 0.00, refundProcessedAt: null, refundReason: 'No refund - cancelled within 24 hours of start time', stripeRefundId: null, payoutStatus: 'pending' }); expect(result.refund).toEqual({ amount: 0.00, percentage: 0.0, reason: 'No refund - cancelled within 24 hours of start time', processed: false, stripeRefundId: null }); }); it('should handle refund without payment intent ID', async () => { mockRental.stripePaymentIntentId = null; // Set to full refund scenario jest.useRealTimers(); jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-28T10:00:00Z')); const result = await RefundService.processCancellation(1, 200); expect(mockCreateRefund).not.toHaveBeenCalled(); expect(mockLoggerWarn).toHaveBeenCalledWith( 'Refund amount calculated but no payment intent ID for rental', { rentalId: 1 } ); expect(result.refund).toEqual({ amount: 100.00, percentage: 1.0, reason: 'Full refund - cancelled by owner', processed: false, stripeRefundId: null }); }); }); describe('Pending rental cancellation (before owner approval)', () => { it('should process cancellation for pending rental without Stripe refund', async () => { // Pending rental with no payment processed yet mockRental.status = 'pending'; mockRental.paymentStatus = 'pending'; mockRental.stripePaymentIntentId = null; jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-28T10:00:00Z')); const result = await RefundService.processCancellation(1, 100, 'Changed my mind'); // No Stripe refund should be attempted expect(mockCreateRefund).not.toHaveBeenCalled(); // Rental should be updated expect(mockRentalUpdate).toHaveBeenCalledWith({ status: 'cancelled', cancelledBy: 'renter', cancelledAt: expect.any(Date), refundAmount: 100.00, refundProcessedAt: null, refundReason: 'Changed my mind', stripeRefundId: null, payoutStatus: 'pending' }); expect(result.refund.processed).toBe(false); expect(result.refund.stripeRefundId).toBeNull(); jest.useRealTimers(); }); }); describe('Error handling', () => { beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-28T10:00:00Z')); }); afterEach(() => { jest.useRealTimers(); }); it('should handle Stripe refund errors', async () => { const stripeError = new Error('Refund failed'); mockCreateRefund.mockRejectedValue(stripeError); await expect(RefundService.processCancellation(1, 200)) .rejects.toThrow('Failed to process refund: Refund failed'); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing Stripe refund', expect.objectContaining({ error: stripeError }) ); }); it('should handle database update errors', async () => { const dbError = new Error('Database update failed'); mockRentalUpdate.mockRejectedValue(dbError); mockCreateRefund.mockResolvedValue({ id: 're_123456789' }); await expect(RefundService.processCancellation(1, 200)) .rejects.toThrow('Database update failed'); }); }); }); describe('getRefundPreview', () => { let mockRental; beforeEach(() => { mockRental = { id: 1, renterId: 100, ownerId: 200, status: 'pending', paymentStatus: 'paid', totalAmount: 150.00, startDateTime: new Date('2023-12-01T10:00:00Z') }; mockRentalFindByPk.mockResolvedValue(mockRental); }); describe('Successful preview', () => { it('should return owner cancellation preview', async () => { const result = await RefundService.getRefundPreview(1, 200); expect(result).toEqual({ canCancel: true, cancelledBy: 'owner', refundAmount: 150.00, refundPercentage: 1.0, reason: 'Full refund - cancelled by owner', totalAmount: 150.00 }); }); it('should return renter cancellation preview with partial refund', async () => { // Set time for 50% refund jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start const result = await RefundService.getRefundPreview(1, 100); expect(result).toEqual({ canCancel: true, cancelledBy: 'renter', refundAmount: 75.00, refundPercentage: 0.5, reason: '50% refund - cancelled between 24-48 hours of start time', totalAmount: 150.00 }); jest.useRealTimers(); }); it('should return renter cancellation preview with no refund', async () => { // Set time for no refund jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-30T15:00:00Z')); const result = await RefundService.getRefundPreview(1, 100); expect(result).toEqual({ canCancel: true, cancelledBy: 'renter', refundAmount: 0.00, refundPercentage: 0.0, reason: 'No refund - cancelled within 24 hours of start time', totalAmount: 150.00 }); jest.useRealTimers(); }); }); describe('Error cases', () => { it('should throw error when rental not found', async () => { mockRentalFindByPk.mockResolvedValue(null); await expect(RefundService.getRefundPreview('999', 100)) .rejects.toThrow('Rental not found'); }); it('should throw error for invalid cancellation', async () => { mockRental.status = 'cancelled'; await expect(RefundService.getRefundPreview(1, 100)) .rejects.toThrow('Rental is already cancelled'); }); it('should throw error for unauthorized user', async () => { await expect(RefundService.getRefundPreview(1, 999)) .rejects.toThrow('You are not authorized to cancel this rental'); }); }); }); describe('Edge cases and error scenarios', () => { it('should handle invalid rental IDs in processCancellation', async () => { mockRentalFindByPk.mockResolvedValue(null); await expect(RefundService.processCancellation('invalid', 100)) .rejects.toThrow('Rental not found'); }); it('should handle very large refund amounts', async () => { const rental = { totalAmount: 999999.99, startDateTime: new Date('2023-12-01T10:00:00Z') }; const result = RefundService.calculateRefundAmount(rental, 'owner'); expect(result.refundAmount).toBe(999999.99); expect(result.refundPercentage).toBe(1.0); }); it('should handle refund amount rounding edge cases', async () => { const rental = { totalAmount: 33.333, startDateTime: new Date('2023-12-01T10:00:00Z') }; // Set time for 50% refund jest.useFakeTimers(); jest.setSystemTime(new Date('2023-11-29T22:00:00Z')); // 36 hours before start const result = RefundService.calculateRefundAmount(rental, 'renter'); expect(result.refundAmount).toBe(16.67); // 33.333 * 0.5 = 16.6665, rounded to 16.67 expect(result.refundPercentage).toBe(0.5); jest.useRealTimers(); }); }); });