/** * DC-055 + DC-057 billing/stripe-client tests. * * Strategy: inject a mock Stripe SDK via _setStripeSdk so no real network * calls ever happen. Cover the key behaviors of the one-time payment flow: * * 1. Configuration validation — missing STRIPE_SECRET_KEY fails loudly with 503. * 2. productId validation — unknown productId returns 400 INVALID_PRODUCT_ID. * 3. Product not configured — Stripe Price ID env var unset returns 503. * 4. Happy path — creates a session with mode:payment + correct price ID + URLs. * 5. Stripe SDK errors — surface as 502 to the customer, not 500. * 6. Metadata contract — emits metadata.productId that the bridge can read back. * 7. payment_intent_data — also carries productId metadata for downstream consumers. * 8. Catalog drives everything — _resolveProduct reads the catalog, not env. */ const stripeClient = require('../../src/billing/stripe-client'); const catalog = require('../../src/billing/catalog'); const REQUIRED_ENV = { STRIPE_SECRET_KEY: '«redacted:sk_test_…»', STRIPE_PRICE_PRO_30D: 'price_30d_test', STRIPE_PRICE_PRO_90D: 'price_90d_test', STRIPE_PRICE_PRO_180D: 'price_180d_test', STRIPE_PRICE_PRO_365D: 'price_365d_test', }; function setEnv(overrides = {}) { const all = { ...REQUIRED_ENV, ...overrides }; for (const [k, v] of Object.entries(all)) { process.env[k] = v; } } function clearEnv() { for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k]; } function makeMockStripe(sessionsCreateImpl) { const sessions = { create: jest.fn().mockImplementation(sessionsCreateImpl) }; return jest.fn().mockReturnValue({ checkout: { sessions } }); } describe('billing/stripe-client', () => { afterEach(() => { clearEnv(); stripeClient._setStripeSdk(null); jest.restoreAllMocks(); }); test('throws STRIPE_NOT_CONFIGURED when STRIPE_SECRET_KEY is missing', async () => { setEnv({ STRIPE_SECRET_KEY: '' }); await expect( stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' }) ).rejects.toMatchObject({ code: 'STRIPE_NOT_CONFIGURED', statusCode: 503, missing: expect.arrayContaining(['STRIPE_SECRET_KEY']), }); }); test('throws STRIPE_NOT_CONFIGURED when 30d product Stripe Price ID is missing', async () => { setEnv({ STRIPE_PRICE_PRO_30D: '' }); await expect( stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' }) ).rejects.toMatchObject({ code: 'STRIPE_NOT_CONFIGURED', missing: expect.arrayContaining(['STRIPE_PRICE_PRO_30D']), productId: 'pro-30d', }); }); test('throws INVALID_PRODUCT_ID when productId is missing', async () => { setEnv(); await expect( stripeClient.createCheckoutSession({ productId: '', origin: 'https://status.sami' }) ).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId' }); }); test('throws INVALID_PRODUCT_ID when productId is unknown', async () => { setEnv(); await expect( stripeClient.createCheckoutSession({ productId: 'pro-1000d', origin: 'https://status.sami' }) ).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId', }); }); test('happy path: pro-30d creates session with mode=payment + correct params', async () => { setEnv(); const mockSession = { id: 'cs_test_abc123', url: 'https://checkout.stripe.com/c/pay/cs_test_abc123' }; const mockStripe = makeMockStripe(async (params) => { // DC-057: one-time payment, NOT subscription. expect(params.mode).toBe('payment'); expect(params.line_items).toEqual([{ price: 'price_30d_test', quantity: 1 }]); expect(params.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}'); expect(params.cancel_url).toBe('https://status.sami/pricing'); // The bridge reads this metadata back to map session → product → duration. expect(params.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' }); // payment_intent_data.metadata mirrors it for downstream Stripe→bridge consumers. expect(params.payment_intent_data).toBeDefined(); expect(params.payment_intent_data.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' }); // No subscription_data on one-time payment. expect(params.subscription_data).toBeUndefined(); return mockSession; }); stripeClient._setStripeSdk(mockStripe); const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami', }); expect(result).toEqual({ id: 'cs_test_abc123', url: mockSession.url }); expect(mockStripe).toHaveBeenCalledWith('«redacted:sk_test_…»'); }); test('happy path: pro-365d uses 365d price ID', async () => { setEnv(); const mockStripe = makeMockStripe(async (params) => { expect(params.line_items[0].price).toBe('price_365d_test'); expect(params.metadata.productId).toBe('pro-365d'); return { id: 'cs_365_xyz', url: 'https://checkout.stripe.com/c/pay/cs_365_xyz' }; }); stripeClient._setStripeSdk(mockStripe); const result = await stripeClient.createCheckoutSession({ productId: 'pro-365d', origin: 'https://status.sami', }); expect(result.id).toBe('cs_365_xyz'); }); test('forwards customerEmail when provided', async () => { setEnv(); const mockStripe = makeMockStripe(async (params) => { expect(params.customer_email).toBe('alice@example.com'); return { id: 'cs_emailed', url: 'https://checkout.stripe.com/c/pay/cs_emailed' }; }); stripeClient._setStripeSdk(mockStripe); await stripeClient.createCheckoutSession({ productId: 'pro-90d', customerEmail: 'alice@example.com', origin: 'https://status.sami', }); }); test('omits customer_email when not provided (no undefined leakage to Stripe)', async () => { setEnv(); const mockStripe = makeMockStripe(async (params) => { expect('customer_email' in params).toBe(false); return { id: 'cs_no_email', url: 'https://checkout.stripe.com/c/pay/cs_no_email' }; }); stripeClient._setStripeSdk(mockStripe); await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami', }); }); test('uses STRIPE_SUCCESS_URL override when set', async () => { setEnv({ STRIPE_SUCCESS_URL: 'https://custom.example.com/thanks' }); const mockStripe = makeMockStripe(async (params) => { expect(params.success_url).toBe('https://custom.example.com/thanks'); return { id: 'cs_custom', url: 'x' }; }); stripeClient._setStripeSdk(mockStripe); await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' }); }); test('uses STRIPE_CANCEL_URL override when set', async () => { setEnv({ STRIPE_CANCEL_URL: 'https://custom.example.com/back' }); const mockStripe = makeMockStripe(async (params) => { expect(params.cancel_url).toBe('https://custom.example.com/back'); return { id: 'cs_cancel', url: 'x' }; }); stripeClient._setStripeSdk(mockStripe); await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' }); }); test('works with relative origin (no host header)', async () => { setEnv(); const mockStripe = makeMockStripe(async () => ({ id: 'x', url: 'x' })); stripeClient._setStripeSdk(mockStripe); const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d' }); expect(result.id).toBe('x'); }); test('each catalog product drives a different price ID', async () => { setEnv(); for (const product of catalog.PRODUCTS) { const mockStripe = makeMockStripe(async (params) => { expect(params.line_items[0].price).toBe(REQUIRED_ENV[product.priceEnv]); expect(params.metadata.productId).toBe(product.id); return { id: `cs_${product.id}`, url: 'x' }; }); stripeClient._setStripeSdk(mockStripe); await stripeClient.createCheckoutSession({ productId: product.id, origin: 'https://status.sami' }); } }); }); describe('billing/stripe-client — _resolveProduct unit', () => { test('resolves known productId with configured price', () => { setEnv(); const result = stripeClient._resolveProduct('pro-30d'); expect(result.product.id).toBe('pro-30d'); expect(result.priceId).toBe('price_30d_test'); }); test('returns INVALID_PRODUCT_ID error for unknown productId', () => { setEnv(); expect(() => stripeClient._resolveProduct('pro-1000d')).toThrow(); try { stripeClient._resolveProduct('pro-1000d'); } catch (e) { expect(e.code).toBe('INVALID_PRODUCT_ID'); expect(e.statusCode).toBe(400); } }); test('returns STRIPE_NOT_CONFIGURED error when product price is unset', () => { setEnv({ STRIPE_PRICE_PRO_180D: '' }); try { stripeClient._resolveProduct('pro-180d'); } catch (e) { expect(e.code).toBe('STRIPE_NOT_CONFIGURED'); expect(e.statusCode).toBe(503); expect(e.missing).toContain('STRIPE_PRICE_PRO_180D'); } }); });