Skip to content

Commit 849aca5

Browse files
test(billing): add trialing race guard tests + fix stale hardening assertion
Add 3 new unit tests in billing.checkout.unit.tests.js covering the Stripe-side live guard: - throws 409 when Stripe returns a trialing sub but DB shows none - throws 409 when Stripe returns an active sub but DB shows none - does NOT block when Stripe returns only an incomplete sub (deliberate asymmetry) Also update the stale assertion in billing.webhook.hardening.unit.tests.js that still expected the old status:'active'/limit:1 params — now correctly expects status:'all'/limit:10 to match the upgraded guard.
1 parent b150df3 commit 849aca5

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

modules/billing/tests/billing.checkout.unit.tests.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,101 @@ describe('Billing service unit tests:', () => {
391391

392392
expect(url).toBe('https://checkout.stripe.com/session_123');
393393
});
394+
395+
// ── Stripe-side live guard (trialing race window) ──────────────────────────────────────────
396+
397+
test('should throw 409 subscription_already_active when Stripe lists a trialing sub but DB shows none', async () => {
398+
jest.unstable_mockModule('../../../config/index.js', () => ({
399+
default: { stripe: { secretKey: 'sk_test_live_trialing' } },
400+
}));
401+
402+
// DB shows no active sub locally — only the Stripe live check should catch this
403+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({
404+
organization: orgId,
405+
stripeCustomerId: 'cus_x',
406+
stripeSubscriptionId: null,
407+
plan: 'free',
408+
status: 'canceled',
409+
});
410+
411+
// Stripe live check returns a trialing sub
412+
mockStripeInstance.subscriptions.list.mockResolvedValue({
413+
data: [{ id: 'sub_trial_x', status: 'trialing' }],
414+
});
415+
416+
const mod = await import('../services/billing.service.js');
417+
BillingService = mod.default;
418+
419+
await expect(
420+
BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'),
421+
).rejects.toMatchObject({ code: 'subscription_already_active', statusCode: 409 });
422+
423+
// Verify the live Stripe check was called with the correct params
424+
expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith(
425+
expect.objectContaining({ customer: 'cus_x', status: 'all', limit: 10 }),
426+
);
427+
expect(mockStripeInstance.checkout.sessions.create).not.toHaveBeenCalled();
428+
});
429+
430+
test('should throw 409 subscription_already_active when Stripe lists an active sub but DB shows none', async () => {
431+
jest.unstable_mockModule('../../../config/index.js', () => ({
432+
default: { stripe: { secretKey: 'sk_test_live_active' } },
433+
}));
434+
435+
// DB shows no active sub locally — only the Stripe live check should catch this
436+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({
437+
organization: orgId,
438+
stripeCustomerId: 'cus_x',
439+
stripeSubscriptionId: null,
440+
plan: 'free',
441+
status: 'canceled',
442+
});
443+
444+
// Stripe live check returns an active sub
445+
mockStripeInstance.subscriptions.list.mockResolvedValue({
446+
data: [{ id: 'sub_active_x', status: 'active' }],
447+
});
448+
449+
const mod = await import('../services/billing.service.js');
450+
BillingService = mod.default;
451+
452+
await expect(
453+
BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'),
454+
).rejects.toMatchObject({ code: 'subscription_already_active', statusCode: 409 });
455+
456+
expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith(
457+
expect.objectContaining({ customer: 'cus_x', status: 'all', limit: 10 }),
458+
);
459+
expect(mockStripeInstance.checkout.sessions.create).not.toHaveBeenCalled();
460+
});
461+
462+
test('should NOT block checkout when Stripe lists an incomplete sub (incomplete is not in block list)', async () => {
463+
jest.unstable_mockModule('../../../config/index.js', () => ({
464+
default: { stripe: { secretKey: 'sk_test_live_incomplete' } },
465+
}));
466+
467+
// DB shows no active sub locally
468+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({
469+
organization: orgId,
470+
stripeCustomerId: 'cus_x',
471+
stripeSubscriptionId: null,
472+
plan: 'free',
473+
status: 'canceled',
474+
});
475+
476+
// Stripe returns only an incomplete sub — should NOT block (deliberate asymmetry vs DB guard)
477+
mockStripeInstance.subscriptions.list.mockResolvedValue({
478+
data: [{ id: 'sub_incomplete_x', status: 'incomplete' }],
479+
});
480+
481+
const mod = await import('../services/billing.service.js');
482+
BillingService = mod.default;
483+
484+
const url = await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel');
485+
486+
expect(url).toBe('https://checkout.stripe.com/session_123');
487+
expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalled();
488+
});
394489
});
395490

396491
describe('createPortalSession', () => {

modules/billing/tests/billing.webhook.hardening.unit.tests.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,7 @@ describe('BillingService.createCheckout — server-side active-sub guard:', () =
777777
jest.restoreAllMocks();
778778
});
779779

780-
test('no live active sub → stripe.subscriptions.list called with active status, checkout proceeds', async () => {
780+
test('no live active/trialing sub → stripe.subscriptions.list called with status:all, checkout proceeds', async () => {
781781
mockStripeInstance.subscriptions.list.mockResolvedValue({ data: [] });
782782

783783
await BillingService.createCheckout(
@@ -787,8 +787,9 @@ describe('BillingService.createCheckout — server-side active-sub guard:', () =
787787
'https://test.example.com/cancel',
788788
);
789789

790+
// Guard upgraded to status:'all' + local filter to catch both 'active' and 'trialing' in one call
790791
expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith(
791-
expect.objectContaining({ status: 'active', limit: 1 }),
792+
expect.objectContaining({ status: 'all', limit: 10 }),
792793
);
793794
expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalled();
794795
});

0 commit comments

Comments
 (0)