Skip to content

Commit 85c0cc5

Browse files
fix(billing): add unit tests for billing service to meet coverage threshold
Global statement coverage was 79.67% (threshold: 80%). Added comprehensive unit tests for createCheckout, createPortalSession, and getSubscription covering all branches (stripe not configured, no subscription, existing subscription without/with customer).
1 parent ac3c02e commit 85c0cc5

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing service
8+
*/
9+
describe('Billing service unit tests:', () => {
10+
let BillingService;
11+
let mockStripeInstance;
12+
let mockSubscriptionRepository;
13+
14+
const orgId = '507f1f77bcf86cd799439011';
15+
const mockOrganization = { _id: orgId, name: 'Test Org' };
16+
17+
beforeEach(async () => {
18+
jest.resetModules();
19+
20+
mockStripeInstance = {
21+
customers: {
22+
create: jest.fn().mockResolvedValue({ id: 'cus_new123' }),
23+
},
24+
checkout: {
25+
sessions: {
26+
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/session_123' }),
27+
},
28+
},
29+
billingPortal: {
30+
sessions: {
31+
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/portal_123' }),
32+
},
33+
},
34+
};
35+
36+
jest.unstable_mockModule('stripe', () => ({
37+
default: jest.fn(() => mockStripeInstance),
38+
}));
39+
40+
mockSubscriptionRepository = {
41+
findByOrganization: jest.fn(),
42+
create: jest.fn(),
43+
update: jest.fn(),
44+
};
45+
46+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
47+
default: mockSubscriptionRepository,
48+
}));
49+
});
50+
51+
afterEach(() => {
52+
jest.restoreAllMocks();
53+
});
54+
55+
describe('createCheckout', () => {
56+
test('should throw when stripe is not configured', async () => {
57+
jest.unstable_mockModule('../../../config/index.js', () => ({
58+
default: { stripe: {} },
59+
}));
60+
61+
const mod = await import('../services/billing.service.js');
62+
BillingService = mod.default;
63+
64+
await expect(BillingService.createCheckout(mockOrganization, 'price_123', 'http://ok', 'http://cancel')).rejects.toThrow(
65+
'Stripe is not configured',
66+
);
67+
});
68+
69+
test('should throw when stripe config is null', async () => {
70+
jest.unstable_mockModule('../../../config/index.js', () => ({
71+
default: {},
72+
}));
73+
74+
const mod = await import('../services/billing.service.js');
75+
BillingService = mod.default;
76+
77+
await expect(BillingService.createCheckout(mockOrganization, 'price_123', 'http://ok', 'http://cancel')).rejects.toThrow(
78+
'Stripe is not configured',
79+
);
80+
});
81+
82+
test('should create customer and subscription when none exists', async () => {
83+
jest.unstable_mockModule('../../../config/index.js', () => ({
84+
default: { stripe: { secretKey: 'sk_test_123' } },
85+
}));
86+
87+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
88+
mockSubscriptionRepository.create.mockResolvedValue({ stripeCustomerId: 'cus_new123' });
89+
90+
const mod = await import('../services/billing.service.js');
91+
BillingService = mod.default;
92+
93+
const url = await BillingService.createCheckout(mockOrganization, 'price_123', 'http://ok', 'http://cancel');
94+
95+
expect(url).toBe('https://checkout.stripe.com/session_123');
96+
expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({
97+
name: 'Test Org',
98+
metadata: { organizationId: orgId },
99+
});
100+
expect(mockSubscriptionRepository.create).toHaveBeenCalledWith({
101+
organization: orgId,
102+
stripeCustomerId: 'cus_new123',
103+
});
104+
});
105+
106+
test('should update existing subscription without stripeCustomerId', async () => {
107+
jest.unstable_mockModule('../../../config/index.js', () => ({
108+
default: { stripe: { secretKey: 'sk_test_456' } },
109+
}));
110+
111+
const existingSub = {
112+
_id: 'sub_existing',
113+
stripeCustomerId: null,
114+
toObject: jest.fn().mockReturnValue({ _id: 'sub_existing', stripeCustomerId: null }),
115+
};
116+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existingSub);
117+
mockSubscriptionRepository.update.mockResolvedValue({ stripeCustomerId: 'cus_new123' });
118+
119+
const mod = await import('../services/billing.service.js');
120+
BillingService = mod.default;
121+
122+
const url = await BillingService.createCheckout(mockOrganization, 'price_123', 'http://ok', 'http://cancel');
123+
124+
expect(url).toBe('https://checkout.stripe.com/session_123');
125+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith({
126+
_id: 'sub_existing',
127+
stripeCustomerId: 'cus_new123',
128+
});
129+
});
130+
131+
test('should use existing customer when subscription has stripeCustomerId', async () => {
132+
jest.unstable_mockModule('../../../config/index.js', () => ({
133+
default: { stripe: { secretKey: 'sk_test_789' } },
134+
}));
135+
136+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({
137+
stripeCustomerId: 'cus_existing',
138+
});
139+
140+
const mod = await import('../services/billing.service.js');
141+
BillingService = mod.default;
142+
143+
const url = await BillingService.createCheckout(mockOrganization, 'price_123', 'http://ok', 'http://cancel');
144+
145+
expect(url).toBe('https://checkout.stripe.com/session_123');
146+
expect(mockStripeInstance.customers.create).not.toHaveBeenCalled();
147+
expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith({
148+
customer: 'cus_existing',
149+
mode: 'subscription',
150+
line_items: [{ price: 'price_123', quantity: 1 }],
151+
success_url: 'http://ok',
152+
cancel_url: 'http://cancel',
153+
});
154+
});
155+
});
156+
157+
describe('createPortalSession', () => {
158+
test('should throw when stripe is not configured', async () => {
159+
jest.unstable_mockModule('../../../config/index.js', () => ({
160+
default: { stripe: {} },
161+
}));
162+
163+
const mod = await import('../services/billing.service.js');
164+
BillingService = mod.default;
165+
166+
await expect(BillingService.createPortalSession(mockOrganization)).rejects.toThrow('Stripe is not configured');
167+
});
168+
169+
test('should throw when no customer found', async () => {
170+
jest.unstable_mockModule('../../../config/index.js', () => ({
171+
default: { stripe: { secretKey: 'sk_test_portal1' } },
172+
}));
173+
174+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
175+
176+
const mod = await import('../services/billing.service.js');
177+
BillingService = mod.default;
178+
179+
await expect(BillingService.createPortalSession(mockOrganization)).rejects.toThrow(
180+
'No Stripe customer found for this organization',
181+
);
182+
});
183+
184+
test('should throw when subscription has no stripeCustomerId', async () => {
185+
jest.unstable_mockModule('../../../config/index.js', () => ({
186+
default: { stripe: { secretKey: 'sk_test_portal2' } },
187+
}));
188+
189+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: null });
190+
191+
const mod = await import('../services/billing.service.js');
192+
BillingService = mod.default;
193+
194+
await expect(BillingService.createPortalSession(mockOrganization)).rejects.toThrow(
195+
'No Stripe customer found for this organization',
196+
);
197+
});
198+
199+
test('should return portal session URL when customer exists', async () => {
200+
jest.unstable_mockModule('../../../config/index.js', () => ({
201+
default: { stripe: { secretKey: 'sk_test_portal3' } },
202+
}));
203+
204+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_portal' });
205+
206+
const mod = await import('../services/billing.service.js');
207+
BillingService = mod.default;
208+
209+
const url = await BillingService.createPortalSession(mockOrganization);
210+
211+
expect(url).toBe('https://billing.stripe.com/portal_123');
212+
expect(mockStripeInstance.billingPortal.sessions.create).toHaveBeenCalledWith({
213+
customer: 'cus_portal',
214+
});
215+
});
216+
});
217+
218+
describe('getSubscription', () => {
219+
test('should return subscription for organization', async () => {
220+
jest.unstable_mockModule('../../../config/index.js', () => ({
221+
default: { stripe: { secretKey: 'sk_test_sub1' } },
222+
}));
223+
224+
const mockSub = { organization: orgId, plan: 'pro' };
225+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(mockSub);
226+
227+
const mod = await import('../services/billing.service.js');
228+
BillingService = mod.default;
229+
230+
const result = await BillingService.getSubscription(orgId);
231+
232+
expect(result).toEqual(mockSub);
233+
expect(mockSubscriptionRepository.findByOrganization).toHaveBeenCalledWith(orgId);
234+
});
235+
236+
test('should return null when no subscription exists', async () => {
237+
jest.unstable_mockModule('../../../config/index.js', () => ({
238+
default: { stripe: { secretKey: 'sk_test_sub2' } },
239+
}));
240+
241+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
242+
243+
const mod = await import('../services/billing.service.js');
244+
BillingService = mod.default;
245+
246+
const result = await BillingService.getSubscription(orgId);
247+
248+
expect(result).toBeNull();
249+
});
250+
});
251+
});

0 commit comments

Comments
 (0)