backend unit tests
This commit is contained in:
940
backend/tests/unit/services/googleMapsService.test.js
Normal file
940
backend/tests/unit/services/googleMapsService.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
743
backend/tests/unit/services/payoutService.test.js
Normal file
743
backend/tests/unit/services/payoutService.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
684
backend/tests/unit/services/refundService.test.js
Normal file
684
backend/tests/unit/services/refundService.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
988
backend/tests/unit/services/stripeService.test.js
Normal file
988
backend/tests/unit/services/stripeService.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user