backend unit tests

This commit is contained in:
jackiettran
2025-09-19 19:46:41 -04:00
parent cf6dd9be90
commit 649289bf90
28 changed files with 17266 additions and 57 deletions

View File

@@ -0,0 +1,940 @@
// Mock the Google Maps client
const mockPlaceAutocomplete = jest.fn();
const mockPlaceDetails = jest.fn();
const mockGeocode = jest.fn();
jest.mock('@googlemaps/google-maps-services-js', () => ({
Client: jest.fn().mockImplementation(() => ({
placeAutocomplete: mockPlaceAutocomplete,
placeDetails: mockPlaceDetails,
geocode: mockGeocode
}))
}));
describe('GoogleMapsService', () => {
let service;
let consoleSpy, consoleErrorSpy;
beforeEach(() => {
// Clear all mocks
jest.clearAllMocks();
// Set up console spies
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Reset environment
delete process.env.GOOGLE_MAPS_API_KEY;
// Clear module cache to get fresh instance
jest.resetModules();
});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('Constructor', () => {
it('should initialize with API key and log success', () => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
expect(consoleSpy).toHaveBeenCalledWith('✅ Google Maps service initialized');
expect(service.isConfigured()).toBe(true);
});
it('should log error when API key is not configured', () => {
delete process.env.GOOGLE_MAPS_API_KEY;
service = require('../../../services/googleMapsService');
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Google Maps API key not configured in environment variables');
expect(service.isConfigured()).toBe(false);
});
it('should initialize Google Maps Client', () => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
const { Client } = require('@googlemaps/google-maps-services-js');
service = require('../../../services/googleMapsService');
expect(Client).toHaveBeenCalledWith({});
});
});
describe('getPlacesAutocomplete', () => {
beforeEach(() => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
});
describe('Input validation', () => {
it('should throw error when API key is not configured', async () => {
service.apiKey = null;
await expect(service.getPlacesAutocomplete('test')).rejects.toThrow('Google Maps API key not configured');
});
it('should return empty predictions for empty input', async () => {
const result = await service.getPlacesAutocomplete('');
expect(result).toEqual({ predictions: [] });
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
});
it('should return empty predictions for input less than 2 characters', async () => {
const result = await service.getPlacesAutocomplete('a');
expect(result).toEqual({ predictions: [] });
expect(mockPlaceAutocomplete).not.toHaveBeenCalled();
});
it('should trim input and proceed with valid input', async () => {
mockPlaceAutocomplete.mockResolvedValue({
data: {
status: 'OK',
predictions: []
}
});
await service.getPlacesAutocomplete(' test ');
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: expect.objectContaining({
input: 'test'
}),
timeout: 5000
});
});
});
describe('Parameters handling', () => {
beforeEach(() => {
mockPlaceAutocomplete.mockResolvedValue({
data: {
status: 'OK',
predictions: []
}
});
});
it('should use default parameters', async () => {
await service.getPlacesAutocomplete('test input');
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: {
key: 'test-api-key',
input: 'test input',
types: 'address',
language: 'en'
},
timeout: 5000
});
});
it('should accept custom options', async () => {
const options = {
types: 'establishment',
language: 'fr'
};
await service.getPlacesAutocomplete('test input', options);
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: {
key: 'test-api-key',
input: 'test input',
types: 'establishment',
language: 'fr'
},
timeout: 5000
});
});
it('should include session token when provided', async () => {
const options = {
sessionToken: 'session-123'
};
await service.getPlacesAutocomplete('test input', options);
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: expect.objectContaining({
sessiontoken: 'session-123'
}),
timeout: 5000
});
});
it('should handle component restrictions', async () => {
const options = {
componentRestrictions: {
country: 'us',
administrative_area: 'CA'
}
};
await service.getPlacesAutocomplete('test input', options);
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: expect.objectContaining({
components: 'country:us|administrative_area:CA'
}),
timeout: 5000
});
});
it('should merge additional options', async () => {
const options = {
radius: 1000,
location: '40.7128,-74.0060'
};
await service.getPlacesAutocomplete('test input', options);
expect(mockPlaceAutocomplete).toHaveBeenCalledWith({
params: expect.objectContaining({
radius: 1000,
location: '40.7128,-74.0060'
}),
timeout: 5000
});
});
});
describe('Successful responses', () => {
it('should return formatted predictions on success', async () => {
const mockResponse = {
data: {
status: 'OK',
predictions: [
{
place_id: 'ChIJ123',
description: 'Test Location, City, State',
types: ['establishment'],
structured_formatting: {
main_text: 'Test Location',
secondary_text: 'City, State'
}
},
{
place_id: 'ChIJ456',
description: 'Another Place',
types: ['locality'],
structured_formatting: {
main_text: 'Another Place'
}
}
]
}
};
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
const result = await service.getPlacesAutocomplete('test input');
expect(result).toEqual({
predictions: [
{
placeId: 'ChIJ123',
description: 'Test Location, City, State',
types: ['establishment'],
mainText: 'Test Location',
secondaryText: 'City, State'
},
{
placeId: 'ChIJ456',
description: 'Another Place',
types: ['locality'],
mainText: 'Another Place',
secondaryText: ''
}
]
});
});
it('should handle predictions without secondary text', async () => {
const mockResponse = {
data: {
status: 'OK',
predictions: [
{
place_id: 'ChIJ123',
description: 'Test Location',
types: ['establishment'],
structured_formatting: {
main_text: 'Test Location'
}
}
]
}
};
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
const result = await service.getPlacesAutocomplete('test input');
expect(result.predictions[0].secondaryText).toBe('');
});
});
describe('Error responses', () => {
it('should handle API error responses', async () => {
const mockResponse = {
data: {
status: 'ZERO_RESULTS',
error_message: 'No results found'
}
};
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
const result = await service.getPlacesAutocomplete('test input');
expect(result).toEqual({
predictions: [],
error: 'No results found for this query',
status: 'ZERO_RESULTS'
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Places Autocomplete API error:',
'ZERO_RESULTS',
'No results found'
);
});
it('should handle unknown error status', async () => {
const mockResponse = {
data: {
status: 'UNKNOWN_STATUS'
}
};
mockPlaceAutocomplete.mockResolvedValue(mockResponse);
const result = await service.getPlacesAutocomplete('test input');
expect(result.error).toBe('Google Maps API error: UNKNOWN_STATUS');
});
it('should handle network errors', async () => {
mockPlaceAutocomplete.mockRejectedValue(new Error('Network error'));
await expect(service.getPlacesAutocomplete('test input')).rejects.toThrow('Failed to fetch place predictions');
expect(consoleErrorSpy).toHaveBeenCalledWith('Places Autocomplete service error:', 'Network error');
});
});
});
describe('getPlaceDetails', () => {
beforeEach(() => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
});
describe('Input validation', () => {
it('should throw error when API key is not configured', async () => {
service.apiKey = null;
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API key not configured');
});
it('should throw error when placeId is not provided', async () => {
await expect(service.getPlaceDetails()).rejects.toThrow('Place ID is required');
await expect(service.getPlaceDetails('')).rejects.toThrow('Place ID is required');
await expect(service.getPlaceDetails(null)).rejects.toThrow('Place ID is required');
});
});
describe('Parameters handling', () => {
beforeEach(() => {
mockPlaceDetails.mockResolvedValue({
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: 'Test Address',
address_components: [],
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
}
});
});
it('should use default parameters', async () => {
await service.getPlaceDetails('ChIJ123');
expect(mockPlaceDetails).toHaveBeenCalledWith({
params: {
key: 'test-api-key',
place_id: 'ChIJ123',
fields: [
'address_components',
'formatted_address',
'geometry',
'place_id'
],
language: 'en'
},
timeout: 5000
});
});
it('should accept custom language', async () => {
await service.getPlaceDetails('ChIJ123', { language: 'fr' });
expect(mockPlaceDetails).toHaveBeenCalledWith({
params: expect.objectContaining({
language: 'fr'
}),
timeout: 5000
});
});
it('should include session token when provided', async () => {
await service.getPlaceDetails('ChIJ123', { sessionToken: 'session-123' });
expect(mockPlaceDetails).toHaveBeenCalledWith({
params: expect.objectContaining({
sessiontoken: 'session-123'
}),
timeout: 5000
});
});
});
describe('Successful responses', () => {
it('should return formatted place details', async () => {
const mockResponse = {
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: '123 Test St, Test City, TC 12345, USA',
address_components: [
{
long_name: '123',
short_name: '123',
types: ['street_number']
},
{
long_name: 'Test Street',
short_name: 'Test St',
types: ['route']
},
{
long_name: 'Test City',
short_name: 'Test City',
types: ['locality', 'political']
},
{
long_name: 'Test State',
short_name: 'TS',
types: ['administrative_area_level_1', 'political']
},
{
long_name: '12345',
short_name: '12345',
types: ['postal_code']
},
{
long_name: 'United States',
short_name: 'US',
types: ['country', 'political']
}
],
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
const result = await service.getPlaceDetails('ChIJ123');
expect(result).toEqual({
placeId: 'ChIJ123',
formattedAddress: '123 Test St, Test City, TC 12345, USA',
addressComponents: {
streetNumber: '123',
route: 'Test Street',
locality: 'Test City',
administrativeAreaLevel1: 'TS',
administrativeAreaLevel1Long: 'Test State',
postalCode: '12345',
country: 'US'
},
geometry: {
latitude: 40.7128,
longitude: -74.0060
}
});
});
it('should handle place details without address components', async () => {
const mockResponse = {
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: 'Test Address',
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
const result = await service.getPlaceDetails('ChIJ123');
expect(result.addressComponents).toEqual({});
});
it('should handle place details without geometry', async () => {
const mockResponse = {
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: 'Test Address'
}
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
const result = await service.getPlaceDetails('ChIJ123');
expect(result.geometry).toEqual({
latitude: 0,
longitude: 0
});
});
it('should handle partial geometry data', async () => {
const mockResponse = {
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: 'Test Address',
geometry: {}
}
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
const result = await service.getPlaceDetails('ChIJ123');
expect(result.geometry).toEqual({
latitude: 0,
longitude: 0
});
});
});
describe('Error responses', () => {
it('should handle API error responses', async () => {
const mockResponse = {
data: {
status: 'NOT_FOUND',
error_message: 'Place not found'
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('The specified place was not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Place Details API error:',
'NOT_FOUND',
'Place not found'
);
});
it('should handle response without result', async () => {
const mockResponse = {
data: {
status: 'OK'
}
};
mockPlaceDetails.mockResolvedValue(mockResponse);
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API error: OK');
});
it('should handle network errors', async () => {
const originalError = new Error('Network error');
mockPlaceDetails.mockRejectedValue(originalError);
await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow(originalError);
expect(consoleErrorSpy).toHaveBeenCalledWith('Place Details service error:', 'Network error');
});
});
});
describe('geocodeAddress', () => {
beforeEach(() => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
});
describe('Input validation', () => {
it('should throw error when API key is not configured', async () => {
service.apiKey = null;
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Google Maps API key not configured');
});
it('should throw error when address is not provided', async () => {
await expect(service.geocodeAddress()).rejects.toThrow('Address is required for geocoding');
await expect(service.geocodeAddress('')).rejects.toThrow('Address is required for geocoding');
await expect(service.geocodeAddress(' ')).rejects.toThrow('Address is required for geocoding');
});
});
describe('Parameters handling', () => {
beforeEach(() => {
mockGeocode.mockResolvedValue({
data: {
status: 'OK',
results: [
{
formatted_address: 'Test Address',
place_id: 'ChIJ123',
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
]
}
});
});
it('should use default parameters', async () => {
await service.geocodeAddress('123 Test St');
expect(mockGeocode).toHaveBeenCalledWith({
params: {
key: 'test-api-key',
address: '123 Test St',
language: 'en'
},
timeout: 5000
});
});
it('should trim address input', async () => {
await service.geocodeAddress(' 123 Test St ');
expect(mockGeocode).toHaveBeenCalledWith({
params: expect.objectContaining({
address: '123 Test St'
}),
timeout: 5000
});
});
it('should accept custom language', async () => {
await service.geocodeAddress('123 Test St', { language: 'fr' });
expect(mockGeocode).toHaveBeenCalledWith({
params: expect.objectContaining({
language: 'fr'
}),
timeout: 5000
});
});
it('should handle component restrictions', async () => {
const options = {
componentRestrictions: {
country: 'us',
administrative_area: 'CA'
}
};
await service.geocodeAddress('123 Test St', options);
expect(mockGeocode).toHaveBeenCalledWith({
params: expect.objectContaining({
components: 'country:us|administrative_area:CA'
}),
timeout: 5000
});
});
it('should handle bounds parameter', async () => {
const options = {
bounds: '40.7,-74.1|40.8,-73.9'
};
await service.geocodeAddress('123 Test St', options);
expect(mockGeocode).toHaveBeenCalledWith({
params: expect.objectContaining({
bounds: '40.7,-74.1|40.8,-73.9'
}),
timeout: 5000
});
});
});
describe('Successful responses', () => {
it('should return geocoded location', async () => {
const mockResponse = {
data: {
status: 'OK',
results: [
{
formatted_address: '123 Test St, Test City, TC 12345, USA',
place_id: 'ChIJ123',
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
]
}
};
mockGeocode.mockResolvedValue(mockResponse);
const result = await service.geocodeAddress('123 Test St');
expect(result).toEqual({
latitude: 40.7128,
longitude: -74.0060,
formattedAddress: '123 Test St, Test City, TC 12345, USA',
placeId: 'ChIJ123'
});
});
it('should return first result when multiple results', async () => {
const mockResponse = {
data: {
status: 'OK',
results: [
{
formatted_address: 'First Result',
place_id: 'ChIJ123',
geometry: { location: { lat: 40.7128, lng: -74.0060 } }
},
{
formatted_address: 'Second Result',
place_id: 'ChIJ456',
geometry: { location: { lat: 40.7129, lng: -74.0061 } }
}
]
}
};
mockGeocode.mockResolvedValue(mockResponse);
const result = await service.geocodeAddress('123 Test St');
expect(result.formattedAddress).toBe('First Result');
expect(result.placeId).toBe('ChIJ123');
});
});
describe('Error responses', () => {
it('should handle API error responses', async () => {
const mockResponse = {
data: {
status: 'ZERO_RESULTS',
error_message: 'No results found'
}
};
mockGeocode.mockResolvedValue(mockResponse);
const result = await service.geocodeAddress('123 Test St');
expect(result).toEqual({
error: 'No results found for this query',
status: 'ZERO_RESULTS'
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Geocoding API error:',
'ZERO_RESULTS',
'No results found'
);
});
it('should handle empty results array', async () => {
const mockResponse = {
data: {
status: 'OK',
results: []
}
};
mockGeocode.mockResolvedValue(mockResponse);
const result = await service.geocodeAddress('123 Test St');
expect(result.error).toBe('Google Maps API error: OK');
});
it('should handle network errors', async () => {
mockGeocode.mockRejectedValue(new Error('Network error'));
await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Failed to geocode address');
expect(consoleErrorSpy).toHaveBeenCalledWith('Geocoding service error:', 'Network error');
});
});
});
describe('getErrorMessage', () => {
beforeEach(() => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
});
it('should return correct error messages for known status codes', () => {
expect(service.getErrorMessage('ZERO_RESULTS')).toBe('No results found for this query');
expect(service.getErrorMessage('OVER_QUERY_LIMIT')).toBe('API quota exceeded. Please try again later');
expect(service.getErrorMessage('REQUEST_DENIED')).toBe('API request denied. Check API key configuration');
expect(service.getErrorMessage('INVALID_REQUEST')).toBe('Invalid request parameters');
expect(service.getErrorMessage('UNKNOWN_ERROR')).toBe('Server error. Please try again');
expect(service.getErrorMessage('NOT_FOUND')).toBe('The specified place was not found');
});
it('should return generic error message for unknown status codes', () => {
expect(service.getErrorMessage('UNKNOWN_STATUS')).toBe('Google Maps API error: UNKNOWN_STATUS');
expect(service.getErrorMessage('CUSTOM_ERROR')).toBe('Google Maps API error: CUSTOM_ERROR');
});
it('should handle null/undefined status', () => {
expect(service.getErrorMessage(null)).toBe('Google Maps API error: null');
expect(service.getErrorMessage(undefined)).toBe('Google Maps API error: undefined');
});
});
describe('isConfigured', () => {
it('should return true when API key is configured', () => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
expect(service.isConfigured()).toBe(true);
});
it('should return false when API key is not configured', () => {
delete process.env.GOOGLE_MAPS_API_KEY;
service = require('../../../services/googleMapsService');
expect(service.isConfigured()).toBe(false);
});
it('should return false when API key is empty string', () => {
process.env.GOOGLE_MAPS_API_KEY = '';
service = require('../../../services/googleMapsService');
expect(service.isConfigured()).toBe(false);
});
});
describe('Singleton pattern', () => {
it('should return the same instance on multiple requires', () => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
const service1 = require('../../../services/googleMapsService');
const service2 = require('../../../services/googleMapsService');
expect(service1).toBe(service2);
});
});
describe('Integration scenarios', () => {
beforeEach(() => {
process.env.GOOGLE_MAPS_API_KEY = 'test-api-key';
service = require('../../../services/googleMapsService');
});
it('should handle typical place search workflow', async () => {
// Mock autocomplete response
mockPlaceAutocomplete.mockResolvedValue({
data: {
status: 'OK',
predictions: [
{
place_id: 'ChIJ123',
description: 'Test Location',
types: ['establishment'],
structured_formatting: {
main_text: 'Test Location',
secondary_text: 'City, State'
}
}
]
}
});
// Mock place details response
mockPlaceDetails.mockResolvedValue({
data: {
status: 'OK',
result: {
place_id: 'ChIJ123',
formatted_address: 'Test Location, City, State',
address_components: [],
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
}
});
// Step 1: Get autocomplete predictions
const autocompleteResult = await service.getPlacesAutocomplete('test loc');
expect(autocompleteResult.predictions).toHaveLength(1);
// Step 2: Get detailed place information
const placeId = autocompleteResult.predictions[0].placeId;
const detailsResult = await service.getPlaceDetails(placeId);
expect(detailsResult.placeId).toBe('ChIJ123');
expect(detailsResult.geometry.latitude).toBe(40.7128);
expect(detailsResult.geometry.longitude).toBe(-74.0060);
});
it('should handle geocoding workflow', async () => {
mockGeocode.mockResolvedValue({
data: {
status: 'OK',
results: [
{
formatted_address: '123 Test St, Test City, TC 12345, USA',
place_id: 'ChIJ123',
geometry: {
location: { lat: 40.7128, lng: -74.0060 }
}
}
]
}
});
const result = await service.geocodeAddress('123 Test St, Test City, TC');
expect(result.latitude).toBe(40.7128);
expect(result.longitude).toBe(-74.0060);
expect(result.formattedAddress).toBe('123 Test St, Test City, TC 12345, USA');
});
});
});

View File

@@ -0,0 +1,743 @@
// Mock dependencies
const mockRentalFindAll = jest.fn();
const mockRentalUpdate = jest.fn();
const mockUserModel = jest.fn();
const mockCreateTransfer = jest.fn();
jest.mock('../../../models', () => ({
Rental: {
findAll: mockRentalFindAll,
update: mockRentalUpdate
},
User: mockUserModel
}));
jest.mock('../../../services/stripeService', () => ({
createTransfer: mockCreateTransfer
}));
jest.mock('sequelize', () => ({
Op: {
not: 'not'
}
}));
const PayoutService = require('../../../services/payoutService');
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
}
}
}
]
});
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 status update to processing
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
payoutStatus: 'processing'
});
// 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).toHaveBeenNthCalledWith(2, {
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 processing status was set
expect(mockRental.update).toHaveBeenNthCalledWith(1, {
payoutStatus: 'processing'
});
// Verify failure status was set
expect(mockRental.update).toHaveBeenNthCalledWith(2, {
payoutStatus: 'failed'
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing payout for rental 1:',
stripeError
);
});
it('should handle database update errors during processing', async () => {
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
.mockResolvedValueOnce(true) // processing update succeeds
.mockRejectedValueOnce(dbError); // completion update fails
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
.mockResolvedValueOnce(true) // processing update succeeds
.mockRejectedValueOnce(updateError); // failed status update fails
// 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).toHaveBeenNthCalledWith(2, {
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
}
}
}
]
});
// 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);
});
});
});

View File

@@ -0,0 +1,684 @@
// 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 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', () => {
const rental = { ...baseRental, status: 'active' };
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 unpaid rental', () => {
const rental = { ...baseRental, 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 failed payment', () => {
const rental = { ...baseRental, paymentStatus: 'failed' };
const result = RefundService.validateCancellationEligibility(rental, 100);
expect(result).toEqual({
canCancel: false,
reason: 'Cannot cancel rental that hasn\'t been paid',
cancelledBy: null
});
});
});
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(consoleWarnSpy).toHaveBeenCalledWith(
'Refund amount calculated but no payment intent ID for rental 1'
);
expect(result.refund).toEqual({
amount: 100.00,
percentage: 1.0,
reason: 'Full refund - cancelled by owner',
processed: false,
stripeRefundId: null
});
});
});
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(consoleErrorSpy).toHaveBeenCalledWith(
'Error processing Stripe refund:',
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();
});
});
});

View File

@@ -0,0 +1,988 @@
// Mock Stripe SDK
const mockStripeCheckoutSessionsRetrieve = jest.fn();
const mockStripeAccountsCreate = jest.fn();
const mockStripeAccountsRetrieve = jest.fn();
const mockStripeAccountLinksCreate = jest.fn();
const mockStripeTransfersCreate = jest.fn();
const mockStripeRefundsCreate = jest.fn();
const mockStripeRefundsRetrieve = jest.fn();
const mockStripePaymentIntentsCreate = jest.fn();
const mockStripeCustomersCreate = jest.fn();
const mockStripeCheckoutSessionsCreate = jest.fn();
jest.mock('stripe', () => {
return jest.fn(() => ({
checkout: {
sessions: {
retrieve: mockStripeCheckoutSessionsRetrieve,
create: mockStripeCheckoutSessionsCreate
}
},
accounts: {
create: mockStripeAccountsCreate,
retrieve: mockStripeAccountsRetrieve
},
accountLinks: {
create: mockStripeAccountLinksCreate
},
transfers: {
create: mockStripeTransfersCreate
},
refunds: {
create: mockStripeRefundsCreate,
retrieve: mockStripeRefundsRetrieve
},
paymentIntents: {
create: mockStripePaymentIntentsCreate
},
customers: {
create: mockStripeCustomersCreate
}
}));
});
const StripeService = require('../../../services/stripeService');
describe('StripeService', () => {
let consoleSpy, consoleErrorSpy;
beforeEach(() => {
jest.clearAllMocks();
// Set up console spies
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Set environment variables for tests
process.env.FRONTEND_URL = 'http://localhost:3000';
});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('getCheckoutSession', () => {
it('should retrieve checkout session successfully', async () => {
const mockSession = {
id: 'cs_123456789',
status: 'complete',
setup_intent: {
id: 'seti_123456789',
payment_method: {
id: 'pm_123456789',
type: 'card'
}
}
};
mockStripeCheckoutSessionsRetrieve.mockResolvedValue(mockSession);
const result = await StripeService.getCheckoutSession('cs_123456789');
expect(mockStripeCheckoutSessionsRetrieve).toHaveBeenCalledWith('cs_123456789', {
expand: ['setup_intent', 'setup_intent.payment_method']
});
expect(result).toEqual(mockSession);
});
it('should handle checkout session retrieval errors', async () => {
const stripeError = new Error('Session not found');
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getCheckoutSession('invalid_session'))
.rejects.toThrow('Session not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error retrieving checkout session:',
stripeError
);
});
it('should handle missing session ID', async () => {
const stripeError = new Error('Invalid session ID');
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getCheckoutSession(null))
.rejects.toThrow('Invalid session ID');
});
});
describe('createConnectedAccount', () => {
it('should create connected account with default country', async () => {
const mockAccount = {
id: 'acct_123456789',
type: 'express',
email: 'test@example.com',
country: 'US',
capabilities: {
transfers: { status: 'pending' }
}
};
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
const result = await StripeService.createConnectedAccount({
email: 'test@example.com'
});
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
type: 'express',
email: 'test@example.com',
country: 'US',
capabilities: {
transfers: { requested: true }
}
});
expect(result).toEqual(mockAccount);
});
it('should create connected account with custom country', async () => {
const mockAccount = {
id: 'acct_123456789',
type: 'express',
email: 'test@example.com',
country: 'CA',
capabilities: {
transfers: { status: 'pending' }
}
};
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
const result = await StripeService.createConnectedAccount({
email: 'test@example.com',
country: 'CA'
});
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
type: 'express',
email: 'test@example.com',
country: 'CA',
capabilities: {
transfers: { requested: true }
}
});
expect(result).toEqual(mockAccount);
});
it('should handle connected account creation errors', async () => {
const stripeError = new Error('Invalid email address');
mockStripeAccountsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createConnectedAccount({
email: 'invalid-email'
})).rejects.toThrow('Invalid email address');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating connected account:',
stripeError
);
});
it('should handle missing email parameter', async () => {
const stripeError = new Error('Email is required');
mockStripeAccountsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createConnectedAccount({}))
.rejects.toThrow('Email is required');
});
});
describe('createAccountLink', () => {
it('should create account link successfully', async () => {
const mockAccountLink = {
object: 'account_link',
url: 'https://connect.stripe.com/setup/e/acct_123456789',
created: Date.now(),
expires_at: Date.now() + 3600
};
mockStripeAccountLinksCreate.mockResolvedValue(mockAccountLink);
const result = await StripeService.createAccountLink(
'acct_123456789',
'http://localhost:3000/refresh',
'http://localhost:3000/return'
);
expect(mockStripeAccountLinksCreate).toHaveBeenCalledWith({
account: 'acct_123456789',
refresh_url: 'http://localhost:3000/refresh',
return_url: 'http://localhost:3000/return',
type: 'account_onboarding'
});
expect(result).toEqual(mockAccountLink);
});
it('should handle account link creation errors', async () => {
const stripeError = new Error('Account not found');
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountLink(
'invalid_account',
'http://localhost:3000/refresh',
'http://localhost:3000/return'
)).rejects.toThrow('Account not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating account link:',
stripeError
);
});
it('should handle invalid URLs', async () => {
const stripeError = new Error('Invalid URL format');
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountLink(
'acct_123456789',
'invalid-url',
'invalid-url'
)).rejects.toThrow('Invalid URL format');
});
});
describe('getAccountStatus', () => {
it('should retrieve account status successfully', async () => {
const mockAccount = {
id: 'acct_123456789',
details_submitted: true,
payouts_enabled: true,
capabilities: {
transfers: { status: 'active' }
},
requirements: {
pending_verification: [],
currently_due: [],
past_due: []
},
other_field: 'should_be_filtered_out'
};
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
const result = await StripeService.getAccountStatus('acct_123456789');
expect(mockStripeAccountsRetrieve).toHaveBeenCalledWith('acct_123456789');
expect(result).toEqual({
id: 'acct_123456789',
details_submitted: true,
payouts_enabled: true,
capabilities: {
transfers: { status: 'active' }
},
requirements: {
pending_verification: [],
currently_due: [],
past_due: []
}
});
});
it('should handle account status retrieval errors', async () => {
const stripeError = new Error('Account not found');
mockStripeAccountsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getAccountStatus('invalid_account'))
.rejects.toThrow('Account not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error retrieving account status:',
stripeError
);
});
it('should handle accounts with incomplete data', async () => {
const mockAccount = {
id: 'acct_123456789',
details_submitted: false,
payouts_enabled: false,
capabilities: null,
requirements: null
};
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
const result = await StripeService.getAccountStatus('acct_123456789');
expect(result).toEqual({
id: 'acct_123456789',
details_submitted: false,
payouts_enabled: false,
capabilities: null,
requirements: null
});
});
});
describe('createTransfer', () => {
it('should create transfer with default currency', async () => {
const mockTransfer = {
id: 'tr_123456789',
amount: 5000, // $50.00 in cents
currency: 'usd',
destination: 'acct_123456789',
metadata: {
rentalId: '1',
ownerId: '2'
}
};
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
const result = await StripeService.createTransfer({
amount: 50.00,
destination: 'acct_123456789',
metadata: {
rentalId: '1',
ownerId: '2'
}
});
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
amount: 5000, // Converted to cents
currency: 'usd',
destination: 'acct_123456789',
metadata: {
rentalId: '1',
ownerId: '2'
}
});
expect(result).toEqual(mockTransfer);
});
it('should create transfer with custom currency', async () => {
const mockTransfer = {
id: 'tr_123456789',
amount: 5000,
currency: 'eur',
destination: 'acct_123456789',
metadata: {}
};
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
const result = await StripeService.createTransfer({
amount: 50.00,
currency: 'eur',
destination: 'acct_123456789'
});
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
amount: 5000,
currency: 'eur',
destination: 'acct_123456789',
metadata: {}
});
expect(result).toEqual(mockTransfer);
});
it('should handle decimal amounts correctly', async () => {
const mockTransfer = {
id: 'tr_123456789',
amount: 12534, // $125.34 in cents
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
};
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
await StripeService.createTransfer({
amount: 125.34,
destination: 'acct_123456789'
});
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
amount: 12534, // Properly converted to cents
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
});
});
it('should handle transfer creation errors', async () => {
const stripeError = new Error('Insufficient funds');
mockStripeTransfersCreate.mockRejectedValue(stripeError);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_123456789'
})).rejects.toThrow('Insufficient funds');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating transfer:',
stripeError
);
});
it('should handle rounding for very small amounts', async () => {
const mockTransfer = {
id: 'tr_123456789',
amount: 1, // $0.005 rounded to 1 cent
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
};
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
await StripeService.createTransfer({
amount: 0.005, // Should round to 1 cent
destination: 'acct_123456789'
});
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
amount: 1,
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
});
});
});
describe('createRefund', () => {
it('should create refund with default parameters', async () => {
const mockRefund = {
id: 're_123456789',
amount: 5000, // $50.00 in cents
payment_intent: 'pi_123456789',
reason: 'requested_by_customer',
status: 'succeeded',
metadata: {
rentalId: '1'
}
};
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
const result = await StripeService.createRefund({
paymentIntentId: 'pi_123456789',
amount: 50.00,
metadata: {
rentalId: '1'
}
});
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
payment_intent: 'pi_123456789',
amount: 5000, // Converted to cents
metadata: {
rentalId: '1'
},
reason: 'requested_by_customer'
});
expect(result).toEqual(mockRefund);
});
it('should create refund with custom reason', async () => {
const mockRefund = {
id: 're_123456789',
amount: 10000,
payment_intent: 'pi_123456789',
reason: 'fraudulent',
status: 'succeeded',
metadata: {}
};
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
const result = await StripeService.createRefund({
paymentIntentId: 'pi_123456789',
amount: 100.00,
reason: 'fraudulent'
});
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
payment_intent: 'pi_123456789',
amount: 10000,
metadata: {},
reason: 'fraudulent'
});
expect(result).toEqual(mockRefund);
});
it('should handle decimal amounts correctly', async () => {
const mockRefund = {
id: 're_123456789',
amount: 12534, // $125.34 in cents
payment_intent: 'pi_123456789',
reason: 'requested_by_customer',
status: 'succeeded',
metadata: {}
};
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
await StripeService.createRefund({
paymentIntentId: 'pi_123456789',
amount: 125.34
});
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
payment_intent: 'pi_123456789',
amount: 12534, // Properly converted to cents
metadata: {},
reason: 'requested_by_customer'
});
});
it('should handle refund creation errors', async () => {
const stripeError = new Error('Payment intent not found');
mockStripeRefundsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createRefund({
paymentIntentId: 'pi_invalid',
amount: 50.00
})).rejects.toThrow('Payment intent not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating refund:',
stripeError
);
});
it('should handle partial refund scenarios', async () => {
const mockRefund = {
id: 're_123456789',
amount: 2500, // Partial refund of $25.00
payment_intent: 'pi_123456789',
reason: 'requested_by_customer',
status: 'succeeded',
metadata: {
type: 'partial'
}
};
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
const result = await StripeService.createRefund({
paymentIntentId: 'pi_123456789',
amount: 25.00,
metadata: {
type: 'partial'
}
});
expect(result.amount).toBe(2500);
expect(result.metadata.type).toBe('partial');
});
});
describe('getRefund', () => {
it('should retrieve refund successfully', async () => {
const mockRefund = {
id: 're_123456789',
amount: 5000,
payment_intent: 'pi_123456789',
reason: 'requested_by_customer',
status: 'succeeded',
created: Date.now()
};
mockStripeRefundsRetrieve.mockResolvedValue(mockRefund);
const result = await StripeService.getRefund('re_123456789');
expect(mockStripeRefundsRetrieve).toHaveBeenCalledWith('re_123456789');
expect(result).toEqual(mockRefund);
});
it('should handle refund retrieval errors', async () => {
const stripeError = new Error('Refund not found');
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getRefund('re_invalid'))
.rejects.toThrow('Refund not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error retrieving refund:',
stripeError
);
});
it('should handle null refund ID', async () => {
const stripeError = new Error('Invalid refund ID');
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getRefund(null))
.rejects.toThrow('Invalid refund ID');
});
});
describe('chargePaymentMethod', () => {
it('should charge payment method successfully', async () => {
const mockPaymentIntent = {
id: 'pi_123456789',
status: 'succeeded',
client_secret: 'pi_123456789_secret_test',
amount: 5000,
currency: 'usd'
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123456789',
50.00,
'cus_123456789',
{ rentalId: '1' }
);
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith({
amount: 5000, // Converted to cents
currency: 'usd',
payment_method: 'pm_123456789',
customer: 'cus_123456789',
confirm: true,
return_url: 'http://localhost:3000/payment-complete',
metadata: { rentalId: '1' }
});
expect(result).toEqual({
paymentIntentId: 'pi_123456789',
status: 'succeeded',
clientSecret: 'pi_123456789_secret_test'
});
});
it('should handle payment method charge errors', async () => {
const stripeError = new Error('Payment method declined');
mockStripePaymentIntentsCreate.mockRejectedValue(stripeError);
await expect(StripeService.chargePaymentMethod(
'pm_invalid',
50.00,
'cus_123456789'
)).rejects.toThrow('Payment method declined');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error charging payment method:',
stripeError
);
});
it('should use default frontend URL when not set', async () => {
delete process.env.FRONTEND_URL;
const mockPaymentIntent = {
id: 'pi_123456789',
status: 'succeeded',
client_secret: 'pi_123456789_secret_test'
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
await StripeService.chargePaymentMethod(
'pm_123456789',
50.00,
'cus_123456789'
);
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
expect.objectContaining({
return_url: 'http://localhost:3000/payment-complete'
})
);
});
it('should handle decimal amounts correctly', async () => {
const mockPaymentIntent = {
id: 'pi_123456789',
status: 'succeeded',
client_secret: 'pi_123456789_secret_test'
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
await StripeService.chargePaymentMethod(
'pm_123456789',
125.34,
'cus_123456789'
);
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
expect.objectContaining({
amount: 12534 // Properly converted to cents
})
);
});
it('should handle payment requiring authentication', async () => {
const mockPaymentIntent = {
id: 'pi_123456789',
status: 'requires_action',
client_secret: 'pi_123456789_secret_test',
next_action: {
type: 'use_stripe_sdk'
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123456789',
50.00,
'cus_123456789'
);
expect(result.status).toBe('requires_action');
expect(result.clientSecret).toBe('pi_123456789_secret_test');
});
});
describe('createCustomer', () => {
it('should create customer successfully', async () => {
const mockCustomer = {
id: 'cus_123456789',
email: 'test@example.com',
name: 'John Doe',
metadata: {
userId: '123'
},
created: Date.now()
};
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
const result = await StripeService.createCustomer({
email: 'test@example.com',
name: 'John Doe',
metadata: {
userId: '123'
}
});
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
email: 'test@example.com',
name: 'John Doe',
metadata: {
userId: '123'
}
});
expect(result).toEqual(mockCustomer);
});
it('should create customer with minimal data', async () => {
const mockCustomer = {
id: 'cus_123456789',
email: 'test@example.com',
name: null,
metadata: {}
};
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
const result = await StripeService.createCustomer({
email: 'test@example.com'
});
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
email: 'test@example.com',
name: undefined,
metadata: {}
});
expect(result).toEqual(mockCustomer);
});
it('should handle customer creation errors', async () => {
const stripeError = new Error('Invalid email format');
mockStripeCustomersCreate.mockRejectedValue(stripeError);
await expect(StripeService.createCustomer({
email: 'invalid-email'
})).rejects.toThrow('Invalid email format');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating customer:',
stripeError
);
});
it('should handle duplicate customer errors', async () => {
const stripeError = new Error('Customer already exists');
mockStripeCustomersCreate.mockRejectedValue(stripeError);
await expect(StripeService.createCustomer({
email: 'existing@example.com',
name: 'Existing User'
})).rejects.toThrow('Customer already exists');
});
});
describe('createSetupCheckoutSession', () => {
it('should create setup checkout session successfully', async () => {
const mockSession = {
id: 'cs_123456789',
url: null,
client_secret: 'cs_123456789_secret_test',
customer: 'cus_123456789',
mode: 'setup',
ui_mode: 'embedded',
metadata: {
type: 'payment_method_setup',
userId: '123'
}
};
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
const result = await StripeService.createSetupCheckoutSession({
customerId: 'cus_123456789',
metadata: {
userId: '123'
}
});
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
customer: 'cus_123456789',
payment_method_types: ['card', 'us_bank_account', 'link'],
mode: 'setup',
ui_mode: 'embedded',
redirect_on_completion: 'never',
metadata: {
type: 'payment_method_setup',
userId: '123'
}
});
expect(result).toEqual(mockSession);
});
it('should create setup checkout session with minimal data', async () => {
const mockSession = {
id: 'cs_123456789',
url: null,
client_secret: 'cs_123456789_secret_test',
customer: 'cus_123456789',
mode: 'setup',
ui_mode: 'embedded',
metadata: {
type: 'payment_method_setup'
}
};
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
const result = await StripeService.createSetupCheckoutSession({
customerId: 'cus_123456789'
});
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
customer: 'cus_123456789',
payment_method_types: ['card', 'us_bank_account', 'link'],
mode: 'setup',
ui_mode: 'embedded',
redirect_on_completion: 'never',
metadata: {
type: 'payment_method_setup'
}
});
expect(result).toEqual(mockSession);
});
it('should handle setup checkout session creation errors', async () => {
const stripeError = new Error('Customer not found');
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createSetupCheckoutSession({
customerId: 'cus_invalid'
})).rejects.toThrow('Customer not found');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating setup checkout session:',
stripeError
);
});
it('should handle missing customer ID', async () => {
const stripeError = new Error('Customer ID is required');
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createSetupCheckoutSession({}))
.rejects.toThrow('Customer ID is required');
});
});
describe('Error handling and edge cases', () => {
it('should handle very large monetary amounts', async () => {
const mockTransfer = {
id: 'tr_123456789',
amount: 99999999, // $999,999.99 in cents
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
};
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
await StripeService.createTransfer({
amount: 999999.99,
destination: 'acct_123456789'
});
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({
amount: 99999999,
currency: 'usd',
destination: 'acct_123456789',
metadata: {}
});
});
it('should handle zero amounts', async () => {
const mockRefund = {
id: 're_123456789',
amount: 0,
payment_intent: 'pi_123456789',
reason: 'requested_by_customer',
status: 'succeeded',
metadata: {}
};
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
await StripeService.createRefund({
paymentIntentId: 'pi_123456789',
amount: 0
});
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({
payment_intent: 'pi_123456789',
amount: 0,
metadata: {},
reason: 'requested_by_customer'
});
});
it('should handle network timeout errors', async () => {
const timeoutError = new Error('Request timeout');
timeoutError.type = 'StripeConnectionError';
mockStripeTransfersCreate.mockRejectedValue(timeoutError);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_123456789'
})).rejects.toThrow('Request timeout');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating transfer:',
timeoutError
);
});
it('should handle API key errors', async () => {
const apiKeyError = new Error('Invalid API key');
apiKeyError.type = 'StripeAuthenticationError';
mockStripeCustomersCreate.mockRejectedValue(apiKeyError);
await expect(StripeService.createCustomer({
email: 'test@example.com'
})).rejects.toThrow('Invalid API key');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error creating customer:',
apiKeyError
);
});
});
});