Skip to content

Commit b4a9294

Browse files
test(billing): add unit tests for subscription schema and plans service
1 parent 13ab6fa commit b4a9294

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing plans service
8+
*/
9+
describe('Billing plans service unit tests:', () => {
10+
let BillingPlansService;
11+
let mockConfig;
12+
let mockStripeInstance;
13+
14+
const mockProducts = {
15+
data: [
16+
{
17+
id: 'prod_pro',
18+
name: 'Pro',
19+
metadata: { planId: 'pro' },
20+
},
21+
{
22+
id: 'prod_starter',
23+
name: 'Starter',
24+
metadata: { planId: 'starter' },
25+
},
26+
],
27+
};
28+
29+
const mockPricesStarter = {
30+
data: [
31+
{ recurring: { interval: 'month' }, unit_amount: 900, id: 'price_starter_m' },
32+
{ recurring: { interval: 'year' }, unit_amount: 9000, id: 'price_starter_y' },
33+
],
34+
};
35+
36+
const mockPricesPro = {
37+
data: [
38+
{ recurring: { interval: 'month' }, unit_amount: 2900, id: 'price_pro_m' },
39+
{ recurring: { interval: 'year' }, unit_amount: 29000, id: 'price_pro_y' },
40+
],
41+
};
42+
43+
beforeEach(async () => {
44+
// Reset modules to clear cached stripe client and plans
45+
jest.resetModules();
46+
47+
mockStripeInstance = {
48+
products: {
49+
list: jest.fn().mockResolvedValue(mockProducts),
50+
},
51+
prices: {
52+
list: jest.fn().mockImplementation(({ product }) => {
53+
if (product === 'prod_starter') return Promise.resolve(mockPricesStarter);
54+
if (product === 'prod_pro') return Promise.resolve(mockPricesPro);
55+
return Promise.resolve({ data: [] });
56+
}),
57+
},
58+
};
59+
60+
// Mock Stripe constructor
61+
jest.unstable_mockModule('stripe', () => ({
62+
default: jest.fn(() => mockStripeInstance),
63+
}));
64+
65+
// Mock config with stripe key
66+
mockConfig = {
67+
stripe: { secretKey: 'sk_test_123' },
68+
};
69+
jest.unstable_mockModule('../../../config/index.js', () => ({
70+
default: mockConfig,
71+
}));
72+
});
73+
74+
afterEach(() => {
75+
jest.restoreAllMocks();
76+
});
77+
78+
test('should return default free plan when stripe is not configured', async () => {
79+
jest.unstable_mockModule('../../../config/index.js', () => ({
80+
default: { stripe: {} },
81+
}));
82+
83+
const mod = await import('../services/billing.plans.service.js');
84+
BillingPlansService = mod.default;
85+
86+
const plans = await BillingPlansService.getPlans();
87+
expect(plans).toEqual([
88+
{
89+
planId: 'free',
90+
name: 'Free',
91+
monthlyPrice: 0,
92+
annualPrice: 0,
93+
stripePriceMonthly: null,
94+
stripePriceAnnual: null,
95+
},
96+
]);
97+
});
98+
99+
test('should return default free plan when stripe config is null', async () => {
100+
jest.unstable_mockModule('../../../config/index.js', () => ({
101+
default: {},
102+
}));
103+
104+
const mod = await import('../services/billing.plans.service.js');
105+
BillingPlansService = mod.default;
106+
107+
const plans = await BillingPlansService.getPlans();
108+
expect(plans).toHaveLength(1);
109+
expect(plans[0].planId).toBe('free');
110+
});
111+
112+
test('should fetch plans from stripe and sort by price', async () => {
113+
const mod = await import('../services/billing.plans.service.js');
114+
BillingPlansService = mod.default;
115+
116+
const plans = await BillingPlansService.getPlans();
117+
expect(plans).toHaveLength(2);
118+
expect(plans[0].name).toBe('Starter');
119+
expect(plans[0].monthlyPrice).toBe(9);
120+
expect(plans[0].annualPrice).toBe(90);
121+
expect(plans[0].stripePriceMonthly).toBe('price_starter_m');
122+
expect(plans[0].stripePriceAnnual).toBe('price_starter_y');
123+
expect(plans[1].name).toBe('Pro');
124+
expect(plans[1].monthlyPrice).toBe(29);
125+
});
126+
127+
test('should use planId from metadata when available', async () => {
128+
const mod = await import('../services/billing.plans.service.js');
129+
BillingPlansService = mod.default;
130+
131+
const plans = await BillingPlansService.getPlans();
132+
expect(plans[0].planId).toBe('starter');
133+
expect(plans[1].planId).toBe('pro');
134+
});
135+
136+
test('should fall back to product id when metadata planId is missing', async () => {
137+
mockStripeInstance.products.list.mockResolvedValue({
138+
data: [{ id: 'prod_basic', name: 'Basic', metadata: {} }],
139+
});
140+
mockStripeInstance.prices.list.mockResolvedValue({ data: [] });
141+
142+
const mod = await import('../services/billing.plans.service.js');
143+
BillingPlansService = mod.default;
144+
145+
const plans = await BillingPlansService.getPlans();
146+
expect(plans[0].planId).toBe('prod_basic');
147+
});
148+
149+
test('should use cached plans on second call', async () => {
150+
const mod = await import('../services/billing.plans.service.js');
151+
BillingPlansService = mod.default;
152+
153+
await BillingPlansService.getPlans();
154+
await BillingPlansService.getPlans();
155+
156+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(1);
157+
});
158+
159+
test('should handle prices without recurring interval', async () => {
160+
mockStripeInstance.products.list.mockResolvedValue({
161+
data: [{ id: 'prod_one', name: 'OneTime', metadata: { planId: 'one' } }],
162+
});
163+
mockStripeInstance.prices.list.mockResolvedValue({
164+
data: [{ unit_amount: 500, id: 'price_one' }],
165+
});
166+
167+
const mod = await import('../services/billing.plans.service.js');
168+
BillingPlansService = mod.default;
169+
170+
const plans = await BillingPlansService.getPlans();
171+
expect(plans[0].monthlyPrice).toBe(0);
172+
expect(plans[0].annualPrice).toBe(0);
173+
expect(plans[0].stripePriceMonthly).toBeNull();
174+
expect(plans[0].stripePriceAnnual).toBeNull();
175+
});
176+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import schema from '../models/subscription.schema.js';
5+
6+
/**
7+
* Unit tests
8+
*/
9+
describe('Billing unit tests:', () => {
10+
describe('Subscription schema', () => {
11+
let subscription;
12+
13+
beforeEach(() => {
14+
subscription = {
15+
organization: '507f1f77bcf86cd799439011',
16+
plan: 'free',
17+
status: 'active',
18+
};
19+
});
20+
21+
test('should be valid a subscription example without problems', (done) => {
22+
const result = schema.Subscription.safeParse(subscription);
23+
expect(typeof result).toBe('object');
24+
expect(result.error).toBeFalsy();
25+
done();
26+
});
27+
28+
test('should be able to show an error when trying a schema without organization', (done) => {
29+
subscription.organization = '';
30+
31+
const result = schema.Subscription.safeParse(subscription);
32+
expect(typeof result).toBe('object');
33+
expect(result.error).toBeDefined();
34+
done();
35+
});
36+
37+
test('should be valid with all optional fields', (done) => {
38+
subscription.stripeCustomerId = 'cus_123';
39+
subscription.stripeSubscriptionId = 'sub_456';
40+
subscription.currentPeriodEnd = '2026-12-31T00:00:00.000Z';
41+
subscription.cancelAtPeriodEnd = true;
42+
43+
const result = schema.Subscription.safeParse(subscription);
44+
expect(typeof result).toBe('object');
45+
expect(result.error).toBeFalsy();
46+
expect(result.data.cancelAtPeriodEnd).toBe(true);
47+
done();
48+
});
49+
50+
test('should default plan to free', (done) => {
51+
delete subscription.plan;
52+
53+
const result = schema.Subscription.safeParse(subscription);
54+
expect(result.error).toBeFalsy();
55+
expect(result.data.plan).toBe('free');
56+
done();
57+
});
58+
59+
test('should default status to active', (done) => {
60+
delete subscription.status;
61+
62+
const result = schema.Subscription.safeParse(subscription);
63+
expect(result.error).toBeFalsy();
64+
expect(result.data.status).toBe('active');
65+
done();
66+
});
67+
68+
test('should reject invalid plan value', (done) => {
69+
subscription.plan = 'invalid';
70+
71+
const result = schema.Subscription.safeParse(subscription);
72+
expect(result.error).toBeDefined();
73+
done();
74+
});
75+
76+
test('should reject invalid status value', (done) => {
77+
subscription.status = 'invalid';
78+
79+
const result = schema.Subscription.safeParse(subscription);
80+
expect(result.error).toBeDefined();
81+
done();
82+
});
83+
84+
test('should accept all valid plan values', (done) => {
85+
for (const plan of ['free', 'starter', 'pro']) {
86+
subscription.plan = plan;
87+
const result = schema.Subscription.safeParse(subscription);
88+
expect(result.error).toBeFalsy();
89+
}
90+
done();
91+
});
92+
93+
test('should accept all valid status values', (done) => {
94+
for (const status of ['active', 'past_due', 'canceled', 'trialing', 'incomplete']) {
95+
subscription.status = status;
96+
const result = schema.Subscription.safeParse(subscription);
97+
expect(result.error).toBeFalsy();
98+
}
99+
done();
100+
});
101+
102+
test('should default cancelAtPeriodEnd to false', (done) => {
103+
const result = schema.Subscription.safeParse(subscription);
104+
expect(result.error).toBeFalsy();
105+
expect(result.data.cancelAtPeriodEnd).toBe(false);
106+
done();
107+
});
108+
109+
test('should strip unknown fields with SubscriptionUpdate', (done) => {
110+
const update = { plan: 'pro', unknown: 'field' };
111+
const result = schema.SubscriptionUpdate.safeParse(update);
112+
expect(result.error).toBeFalsy();
113+
expect(result.data?.unknown).toBeUndefined();
114+
done();
115+
});
116+
117+
test('should allow partial updates with SubscriptionUpdate', (done) => {
118+
const update = { plan: 'starter' };
119+
const result = schema.SubscriptionUpdate.safeParse(update);
120+
expect(result.error).toBeFalsy();
121+
expect(result.data.plan).toBe('starter');
122+
done();
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)