Files
rentall-app/backend/tests/unit/services/payoutService.test.js
jackiettran 3f319bfdd0 unit tests
2025-12-12 16:27:56 -05:00

747 lines
22 KiB
JavaScript

// Mock dependencies - define mocks inline to avoid hoisting issues
jest.mock('../../../models', () => ({
Rental: {
findAll: jest.fn(),
update: jest.fn()
},
User: jest.fn(),
Item: jest.fn()
}));
jest.mock('../../../services/stripeService', () => ({
createTransfer: jest.fn()
}));
jest.mock('sequelize', () => ({
Op: {
not: 'not'
}
}));
const PayoutService = require('../../../services/payoutService');
const { Rental, User, Item } = require('../../../models');
const StripeService = require('../../../services/stripeService');
// Get references to mocks after importing
const mockRentalFindAll = Rental.findAll;
const mockRentalUpdate = Rental.update;
const mockUserModel = User;
const mockItemModel = Item;
const mockCreateTransfer = StripeService.createTransfer;
describe('PayoutService', () => {
let consoleSpy, consoleErrorSpy;
beforeEach(() => {
jest.clearAllMocks();
// Set up console spies
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('getEligiblePayouts', () => {
it('should return eligible rentals for payout', async () => {
const mockRentals = [
{
id: 1,
status: 'completed',
paymentStatus: 'paid',
payoutStatus: 'pending',
owner: {
id: 1,
stripeConnectedAccountId: 'acct_123'
}
},
{
id: 2,
status: 'completed',
paymentStatus: 'paid',
payoutStatus: 'pending',
owner: {
id: 2,
stripeConnectedAccountId: 'acct_456'
}
}
];
mockRentalFindAll.mockResolvedValue(mockRentals);
const result = await PayoutService.getEligiblePayouts();
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: {
status: 'completed',
paymentStatus: 'paid',
payoutStatus: 'pending'
},
include: [
{
model: mockUserModel,
as: 'owner',
where: {
stripeConnectedAccountId: {
'not': null
}
}
},
{
model: mockItemModel,
as: 'item'
}
]
});
expect(result).toEqual(mockRentals);
});
it('should handle database errors', async () => {
const dbError = new Error('Database connection failed');
mockRentalFindAll.mockRejectedValue(dbError);
await expect(PayoutService.getEligiblePayouts()).rejects.toThrow('Database connection failed');
expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting eligible payouts:', dbError);
});
it('should return empty array when no eligible rentals found', async () => {
mockRentalFindAll.mockResolvedValue([]);
const result = await PayoutService.getEligiblePayouts();
expect(result).toEqual([]);
});
});
describe('processRentalPayout', () => {
let mockRental;
beforeEach(() => {
mockRental = {
id: 1,
ownerId: 2,
payoutStatus: 'pending',
payoutAmount: 9500, // $95.00
totalAmount: 10000, // $100.00
platformFee: 500, // $5.00
startDateTime: new Date('2023-01-01T10:00:00Z'),
endDateTime: new Date('2023-01-02T10:00:00Z'),
owner: {
id: 2,
stripeConnectedAccountId: 'acct_123'
},
update: jest.fn().mockResolvedValue(true)
};
});
describe('Validation', () => {
it('should throw error when owner has no connected Stripe account', async () => {
mockRental.owner.stripeConnectedAccountId = null;
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Owner does not have a connected Stripe account');
});
it('should throw error when owner is missing', async () => {
mockRental.owner = null;
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Owner does not have a connected Stripe account');
});
it('should throw error when payout already processed', async () => {
mockRental.payoutStatus = 'completed';
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Rental payout has already been processed');
});
it('should throw error when payout amount is invalid', async () => {
mockRental.payoutAmount = 0;
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Invalid payout amount');
});
it('should throw error when payout amount is negative', async () => {
mockRental.payoutAmount = -100;
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Invalid payout amount');
});
it('should throw error when payout amount is null', async () => {
mockRental.payoutAmount = null;
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Invalid payout amount');
});
});
describe('Successful processing', () => {
beforeEach(() => {
mockCreateTransfer.mockResolvedValue({
id: 'tr_123456789',
amount: 9500,
destination: 'acct_123'
});
});
it('should successfully process a rental payout', async () => {
const result = await PayoutService.processRentalPayout(mockRental);
// Verify Stripe transfer creation
expect(mockCreateTransfer).toHaveBeenCalledWith({
amount: 9500,
destination: 'acct_123',
metadata: {
rentalId: 1,
ownerId: 2,
totalAmount: '10000',
platformFee: '500',
startDateTime: '2023-01-01T10:00:00.000Z',
endDateTime: '2023-01-02T10:00:00.000Z'
}
});
// Verify status update to completed
expect(mockRental.update).toHaveBeenCalledWith({
payoutStatus: 'completed',
payoutProcessedAt: expect.any(Date),
stripeTransferId: 'tr_123456789'
});
// Verify success log
expect(consoleSpy).toHaveBeenCalledWith(
'Payout completed for rental 1: $9500 to acct_123'
);
// Verify return value
expect(result).toEqual({
success: true,
transferId: 'tr_123456789',
amount: 9500
});
});
it('should handle successful payout with different amounts', async () => {
mockRental.payoutAmount = 15000;
mockRental.totalAmount = 16000;
mockRental.platformFee = 1000;
mockCreateTransfer.mockResolvedValue({
id: 'tr_987654321',
amount: 15000,
destination: 'acct_123'
});
const result = await PayoutService.processRentalPayout(mockRental);
expect(mockCreateTransfer).toHaveBeenCalledWith({
amount: 15000,
destination: 'acct_123',
metadata: expect.objectContaining({
totalAmount: '16000',
platformFee: '1000'
})
});
expect(result.amount).toBe(15000);
expect(result.transferId).toBe('tr_987654321');
});
});
describe('Error handling', () => {
it('should handle Stripe transfer creation errors', async () => {
const stripeError = new Error('Stripe transfer failed');
mockCreateTransfer.mockRejectedValue(stripeError);
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Stripe transfer failed');
// Verify failure status was set
expect(mockRental.update).toHaveBeenCalledWith({
payoutStatus: 'failed'
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing payout for rental 1:',
stripeError
);
});
it('should handle database update errors during processing', async () => {
// Stripe succeeds but database update fails
mockCreateTransfer.mockResolvedValue({
id: 'tr_123456789',
amount: 9500
});
const dbError = new Error('Database update failed');
mockRental.update.mockRejectedValueOnce(dbError);
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Database update failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing payout for rental 1:',
dbError
);
});
it('should handle database update errors during completion', async () => {
mockCreateTransfer.mockResolvedValue({
id: 'tr_123456789',
amount: 9500
});
const dbError = new Error('Database completion update failed');
mockRental.update.mockRejectedValueOnce(dbError);
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Database completion update failed');
expect(mockCreateTransfer).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing payout for rental 1:',
dbError
);
});
it('should handle failure status update errors gracefully', async () => {
const stripeError = new Error('Stripe transfer failed');
const updateError = new Error('Update failed status failed');
mockCreateTransfer.mockRejectedValue(stripeError);
mockRental.update.mockRejectedValueOnce(updateError);
// The service will throw the update error since it happens in the catch block
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Update failed status failed');
// Should still attempt to update to failed status
expect(mockRental.update).toHaveBeenCalledWith({
payoutStatus: 'failed'
});
});
});
});
describe('processAllEligiblePayouts', () => {
beforeEach(() => {
jest.spyOn(PayoutService, 'getEligiblePayouts');
jest.spyOn(PayoutService, 'processRentalPayout');
});
afterEach(() => {
PayoutService.getEligiblePayouts.mockRestore();
PayoutService.processRentalPayout.mockRestore();
});
it('should process all eligible payouts successfully', async () => {
const mockRentals = [
{ id: 1, payoutAmount: 9500 },
{ id: 2, payoutAmount: 7500 }
];
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
PayoutService.processRentalPayout
.mockResolvedValueOnce({
success: true,
transferId: 'tr_123',
amount: 9500
})
.mockResolvedValueOnce({
success: true,
transferId: 'tr_456',
amount: 7500
});
const result = await PayoutService.processAllEligiblePayouts();
expect(consoleSpy).toHaveBeenCalledWith('Found 2 eligible rentals for payout');
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 0 failed');
expect(result).toEqual({
successful: [
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
{ rentalId: 2, amount: 7500, transferId: 'tr_456' }
],
failed: [],
totalProcessed: 2
});
});
it('should handle mixed success and failure results', async () => {
const mockRentals = [
{ id: 1, payoutAmount: 9500 },
{ id: 2, payoutAmount: 7500 },
{ id: 3, payoutAmount: 12000 }
];
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
PayoutService.processRentalPayout
.mockResolvedValueOnce({
success: true,
transferId: 'tr_123',
amount: 9500
})
.mockRejectedValueOnce(new Error('Stripe account suspended'))
.mockResolvedValueOnce({
success: true,
transferId: 'tr_789',
amount: 12000
});
const result = await PayoutService.processAllEligiblePayouts();
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
expect(result).toEqual({
successful: [
{ rentalId: 1, amount: 9500, transferId: 'tr_123' },
{ rentalId: 3, amount: 12000, transferId: 'tr_789' }
],
failed: [
{ rentalId: 2, error: 'Stripe account suspended' }
],
totalProcessed: 3
});
});
it('should handle no eligible payouts', async () => {
PayoutService.getEligiblePayouts.mockResolvedValue([]);
const result = await PayoutService.processAllEligiblePayouts();
expect(consoleSpy).toHaveBeenCalledWith('Found 0 eligible rentals for payout');
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 0 successful, 0 failed');
expect(result).toEqual({
successful: [],
failed: [],
totalProcessed: 0
});
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
});
it('should handle errors in getEligiblePayouts', async () => {
const dbError = new Error('Database connection failed');
PayoutService.getEligiblePayouts.mockRejectedValue(dbError);
await expect(PayoutService.processAllEligiblePayouts())
.rejects.toThrow('Database connection failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing all eligible payouts:',
dbError
);
});
it('should handle all payouts failing', async () => {
const mockRentals = [
{ id: 1, payoutAmount: 9500 },
{ id: 2, payoutAmount: 7500 }
];
PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals);
PayoutService.processRentalPayout
.mockRejectedValueOnce(new Error('Transfer failed'))
.mockRejectedValueOnce(new Error('Account not found'));
const result = await PayoutService.processAllEligiblePayouts();
expect(result).toEqual({
successful: [],
failed: [
{ rentalId: 1, error: 'Transfer failed' },
{ rentalId: 2, error: 'Account not found' }
],
totalProcessed: 2
});
});
});
describe('retryFailedPayouts', () => {
beforeEach(() => {
jest.spyOn(PayoutService, 'processRentalPayout');
});
afterEach(() => {
PayoutService.processRentalPayout.mockRestore();
});
it('should retry failed payouts successfully', async () => {
const mockFailedRentals = [
{
id: 1,
payoutAmount: 9500,
update: jest.fn().mockResolvedValue(true)
},
{
id: 2,
payoutAmount: 7500,
update: jest.fn().mockResolvedValue(true)
}
];
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
PayoutService.processRentalPayout
.mockResolvedValueOnce({
success: true,
transferId: 'tr_retry_123',
amount: 9500
})
.mockResolvedValueOnce({
success: true,
transferId: 'tr_retry_456',
amount: 7500
});
const result = await PayoutService.retryFailedPayouts();
// Verify query for failed rentals
expect(mockRentalFindAll).toHaveBeenCalledWith({
where: {
status: 'completed',
paymentStatus: 'paid',
payoutStatus: 'failed'
},
include: [
{
model: mockUserModel,
as: 'owner',
where: {
stripeConnectedAccountId: {
'not': null
}
}
},
{
model: mockItemModel,
as: 'item'
}
]
});
// Verify status reset to pending
expect(mockFailedRentals[0].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
expect(mockFailedRentals[1].update).toHaveBeenCalledWith({ payoutStatus: 'pending' });
// Verify processing attempts
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[0]);
expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[1]);
// Verify logs
expect(consoleSpy).toHaveBeenCalledWith('Found 2 failed payouts to retry');
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 2 successful, 0 failed');
// Verify result
expect(result).toEqual({
successful: [
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' },
{ rentalId: 2, amount: 7500, transferId: 'tr_retry_456' }
],
failed: [],
totalProcessed: 2
});
});
it('should handle mixed retry results', async () => {
const mockFailedRentals = [
{
id: 1,
payoutAmount: 9500,
update: jest.fn().mockResolvedValue(true)
},
{
id: 2,
payoutAmount: 7500,
update: jest.fn().mockResolvedValue(true)
}
];
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
PayoutService.processRentalPayout
.mockResolvedValueOnce({
success: true,
transferId: 'tr_retry_123',
amount: 9500
})
.mockRejectedValueOnce(new Error('Still failing'));
const result = await PayoutService.retryFailedPayouts();
expect(result).toEqual({
successful: [
{ rentalId: 1, amount: 9500, transferId: 'tr_retry_123' }
],
failed: [
{ rentalId: 2, error: 'Still failing' }
],
totalProcessed: 2
});
});
it('should handle no failed payouts to retry', async () => {
mockRentalFindAll.mockResolvedValue([]);
const result = await PayoutService.retryFailedPayouts();
expect(consoleSpy).toHaveBeenCalledWith('Found 0 failed payouts to retry');
expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 0 successful, 0 failed');
expect(result).toEqual({
successful: [],
failed: [],
totalProcessed: 0
});
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
});
it('should handle errors in finding failed rentals', async () => {
const dbError = new Error('Database query failed');
mockRentalFindAll.mockRejectedValue(dbError);
await expect(PayoutService.retryFailedPayouts())
.rejects.toThrow('Database query failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error retrying failed payouts:',
dbError
);
});
it('should handle status reset errors', async () => {
const mockFailedRentals = [
{
id: 1,
payoutAmount: 9500,
update: jest.fn().mockRejectedValue(new Error('Status reset failed'))
}
];
mockRentalFindAll.mockResolvedValue(mockFailedRentals);
const result = await PayoutService.retryFailedPayouts();
expect(result.failed).toEqual([
{ rentalId: 1, error: 'Status reset failed' }
]);
expect(PayoutService.processRentalPayout).not.toHaveBeenCalled();
});
});
describe('Error logging', () => {
it('should log errors with rental context in processRentalPayout', async () => {
const mockRental = {
id: 123,
payoutStatus: 'pending',
payoutAmount: 9500,
owner: {
stripeConnectedAccountId: 'acct_123'
},
update: jest.fn().mockRejectedValue(new Error('Update failed'))
};
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Update failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing payout for rental 123:',
expect.any(Error)
);
});
it('should log aggregate results in processAllEligiblePayouts', async () => {
jest.spyOn(PayoutService, 'getEligiblePayouts').mockResolvedValue([
{ id: 1 }, { id: 2 }, { id: 3 }
]);
jest.spyOn(PayoutService, 'processRentalPayout')
.mockResolvedValueOnce({ amount: 100, transferId: 'tr_1' })
.mockRejectedValueOnce(new Error('Failed'))
.mockResolvedValueOnce({ amount: 300, transferId: 'tr_3' });
await PayoutService.processAllEligiblePayouts();
expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout');
expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed');
PayoutService.getEligiblePayouts.mockRestore();
PayoutService.processRentalPayout.mockRestore();
});
});
describe('Edge cases', () => {
it('should handle rental with undefined owner', async () => {
const mockRental = {
id: 1,
payoutStatus: 'pending',
payoutAmount: 9500,
owner: undefined,
update: jest.fn()
};
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Owner does not have a connected Stripe account');
});
it('should handle rental with empty string Stripe account ID', async () => {
const mockRental = {
id: 1,
payoutStatus: 'pending',
payoutAmount: 9500,
owner: {
stripeConnectedAccountId: ''
},
update: jest.fn()
};
await expect(PayoutService.processRentalPayout(mockRental))
.rejects.toThrow('Owner does not have a connected Stripe account');
});
it('should handle very large payout amounts', async () => {
const mockRental = {
id: 1,
ownerId: 2,
payoutStatus: 'pending',
payoutAmount: 999999999, // Very large amount
totalAmount: 1000000000,
platformFee: 1,
startDateTime: new Date('2023-01-01T10:00:00Z'),
endDateTime: new Date('2023-01-02T10:00:00Z'),
owner: {
stripeConnectedAccountId: 'acct_123'
},
update: jest.fn().mockResolvedValue(true)
};
mockCreateTransfer.mockResolvedValue({
id: 'tr_large_amount',
amount: 999999999
});
const result = await PayoutService.processRentalPayout(mockRental);
expect(mockCreateTransfer).toHaveBeenCalledWith({
amount: 999999999,
destination: 'acct_123',
metadata: expect.objectContaining({
totalAmount: '1000000000',
platformFee: '1'
})
});
expect(result.amount).toBe(999999999);
});
});
});