/** * DC-054 + DC-057 stripe-license-bridge tests. * * Strategy: no live network, no live Stripe SDK. We use `jest.mock` to * substitute license-keygen + nodemailer before the bridge loads, drive * handleWebhook() with crafted raw bodies + signatures. * * Coverage: * - Signature validation (pass / missing / wrong / out-of-tolerance) * - JSON parse failure * - Duplicate event-id → 200 idempotent * - Two different events for the SAME session → single license (layer-2 idempotency) * - License persisted BEFORE email (crash-safety) * - Email failure → markDeliveryFailed → returns 500 → customer can retrieve via lookup * - Retry from pending_email delivers the SAME code * - Concurrent lease (busy) returns 409 * - Catalog resolution: missing productId → 400; unknown productId → 400; * product-not-configured → 400 * - Lookup endpoint: not_found, processing, pending_email, delivered, expired TTL * - Layer-1 + Layer-2 idempotency under Stripe retry */ // jest.mock must be hoisted before any require. jest.mock('../../license-keygen', () => { const crypto = require('crypto'); let calls = 0; return { VALID_DURATIONS: [30, 90, 180, 365], loadSecret: () => 'mock-license-secret-' + crypto.randomBytes(8).toString('hex'), generateCodes: jest.fn(({ durationDays, count }) => { calls++; const codes = []; for (let i = 0; i < count; i++) { codes.push({ code: `DC-TEST-${durationDays}D-${crypto.randomBytes(4).toString('hex').toUpperCase()}`, codeId: `codeid_${Date.now()}_${i}_${calls}`, }); } return codes; }), __resetGenerateCalls() { calls = 0; }, __getGenerateCalls() { return calls; }, }; }); jest.mock('nodemailer', () => ({ createTransport: jest.fn(() => ({ sendMail: jest.fn(), })), })); const path = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); // Set up isolated tmp dirs BEFORE requiring the bridge (it captures paths at require time). const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-')); process.env.STRIPE_BRIDGE_STATE_DIR = TMP; process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json'); process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json'); // Use a unique webhook secret so tests don't pollute each other. process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex'); // Configure all Stripe Prices so catalog.getConfiguredProducts() returns them. process.env.STRIPE_PRICE_PRO_30D = 'price_30d_test'; process.env.STRIPE_PRICE_PRO_90D = 'price_90d_test'; process.env.STRIPE_PRICE_PRO_180D = 'price_180d_test'; process.env.STRIPE_PRICE_PRO_365D = 'price_365d_test'; // Disable SMTP so the bridge falls back to dev-console unless a test // explicitly injects nodemailer. delete process.env.SMTP_HOST; delete process.env.SMTP_FROM; const licenseKeygenMock = require('../../license-keygen'); const nodemailerMock = require('nodemailer'); const bridge = require('../../scripts/stripe-license-bridge'); const catalog = require('../../src/billing/catalog'); const { createFulfillmentStore } = require('../../src/billing/fulfillment-store'); afterEach(() => { delete process.env.SMTP_HOST; delete process.env.SMTP_FROM; licenseKeygenMock.__resetGenerateCalls(); // Reset nodemailer.sendMail mock implementations between tests. nodemailerMock.createTransport.mockClear(); }); // Helper: build a signed Stripe webhook payload. function buildSignedPayload(body, opts = {}) { const secret = opts.secret || process.env.STRIPE_WEBHOOK_SECRET; const ts = opts.timestamp || Math.floor(Date.now() / 1000); const rawBody = Buffer.from(JSON.stringify(body)); const sig = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex'); const header = `t=${ts},v1=${sig}`; return { rawBody, signatureHeader: header }; } function buildSessionEvent({ productId = 'pro-30d', sessionId, customerEmail = 'alice@example.com', eventId, lineItems, paymentStatus = 'paid' }) { const product = catalog.getProduct(productId); // For tests of "unknown productId" the catalog.getProduct returns null — // we still build a valid event so the bridge can return its own 400. const priceId = product ? catalog.getConfiguredPrice(product) : 'price_unconfigured'; return { id: eventId || `evt_${crypto.randomBytes(6).toString('hex')}`, type: 'checkout.session.completed', data: { object: { id: sessionId || `cs_test_${crypto.randomBytes(6).toString('hex')}`, customer_email: customerEmail, customer_details: { email: customerEmail }, payment_status: paymentStatus, amount_total: product ? product.amountCents : 0, currency: 'usd', metadata: { productId, product: 'dashcaddy-pro' }, line_items: { data: lineItems || [{ price: { id: priceId } }] }, }, }, }; } function injectSmtp(impl) { nodemailerMock.createTransport.mockImplementation(() => ({ sendMail: jest.fn().mockImplementation(impl), })); } describe('stripe-license-bridge signature verification', () => { test('rejects missing signature header', async () => { const { rawBody } = buildSignedPayload({ id: 'evt_1', type: 'x' }); const result = await bridge.handleWebhook({ rawBody, signatureHeader: '' }); expect(result.status).toBe(400); expect(result.body.reason).toBe('signature-missing-signature'); }); test('rejects wrong signature', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); const ts = Math.floor(Date.now() / 1000); const rawBody = Buffer.from(JSON.stringify(event)); const sig = crypto.createHmac('sha256', 'wrong').update(`${ts}.${rawBody}`, 'utf8').digest('hex'); const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` }); expect(result.status).toBe(400); expect(result.body.reason).toMatch(/^signature-/); }); test('rejects out-of-tolerance timestamp', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); const oldTs = Math.floor(Date.now() / 1000) - 3600; // 1h ago, > 300s tolerance const { rawBody, signatureHeader } = buildSignedPayload(event, { timestamp: oldTs }); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('signature-timestamp-out-of-tolerance'); }); }); describe('stripe-license-bridge event parsing', () => { test('rejects invalid JSON', async () => { const rawBody = Buffer.from('not json'); const ts = Math.floor(Date.now() / 1000); const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`, 'utf8').digest('hex'); const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` }); expect(result.status).toBe(400); expect(result.body.reason).toBe('invalid-json'); }); test('rejects event without id', async () => { const event = { type: 'checkout.session.completed', data: { object: {} } }; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('invalid-event'); }); test('acks unknown event types with 200 (so Stripe stops retrying)', async () => { const event = { id: 'evt_unknown', type: 'customer.created', data: { object: {} } }; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.reason).toBe('ignored-event-type'); }); }); describe('stripe-license-bridge catalog resolution', () => { test('rejects session without productId metadata', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); delete event.data.object.metadata.productId; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('missing-productId'); }); test('rejects unknown productId', async () => { const event = buildSessionEvent({ productId: 'pro-1000d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('unknown-productId'); }); test('rejects when product Stripe Price is unconfigured', async () => { const productId = 'pro-30d'; const saved = process.env.STRIPE_PRICE_PRO_30D; delete process.env.STRIPE_PRICE_PRO_30D; try { const event = buildSessionEvent({ productId }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('product-not-configured'); } finally { process.env.STRIPE_PRICE_PRO_30D = saved; } }); test('rejects when customer email is missing', async () => { const event = buildSessionEvent({ productId: 'pro-30d', customerEmail: '' }); delete event.data.object.customer_email; delete event.data.object.customer_details.email; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(400); expect(result.body.reason).toBe('missing-customer-email'); }); test('accepts sessions without expanded line_items (Stripe webhook default)', async () => { // DC-057 acceptance: Stripe does NOT expand line_items in webhooks by // default — the bridge must accept the canonical metadata.productId // even when line_items is absent. (Price verification, when added, // should be an optional belt-and-suspenders via a separate API call, // not a hard requirement.) const event = buildSessionEvent({ productId: 'pro-30d' }); delete event.data.object.line_items; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(result.body.productId).toBe('pro-30d'); expect(result.body.durationDays).toBe(30); }); test('rejects unpaid sessions (no license until payment clears)', async () => { // DC-057: a checkout.session.completed event with payment_status='unpaid' // arrives when the customer closes the browser mid-checkout or for // delayed-payment methods (ACH/SEPA) before they clear. The bridge // MUST ack 200 (so Stripe stops retrying) but MUST NOT generate a // license. The async_payment_succeeded event will fire later. const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'unpaid' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(false); expect(result.body.reason).toBe('payment-not-unpaid'); expect(licenseKeygenMock.__getGenerateCalls()).toBe(0); }); test('rejects no_payment_required sessions (DashCaddy does not sell free products)', async () => { // 'no_payment_required' is a Stripe-internal edge case for free // sessions. DashCaddy has no $0 product, so reject explicitly. const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'no_payment_required' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(false); expect(result.body.reason).toBe('payment-not-no_payment_required'); expect(licenseKeygenMock.__getGenerateCalls()).toBe(0); }); test('rejects sessions with missing payment_status', async () => { const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: '' }); delete event.data.object.payment_status; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(false); expect(result.body.reason).toBe('payment-not-confirmed'); expect(licenseKeygenMock.__getGenerateCalls()).toBe(0); }); test('fulfills async_payment_succeeded events for delayed-payment methods', async () => { // ACH/SEPA: Stripe first sends checkout.session.completed (unpaid), // then async_payment_succeeded (paid) when the bank clears. The // bridge generates the license on the second event. const sessionId = `cs_test_ach_${crypto.randomBytes(4).toString('hex')}`; const event = { id: `evt_ach_${crypto.randomBytes(6).toString('hex')}`, type: 'checkout.session.async_payment_succeeded', data: { object: { id: sessionId, customer_email: 'alice@example.com', customer_details: { email: 'alice@example.com' }, payment_status: 'paid', amount_total: 5000, currency: 'usd', metadata: { productId: 'pro-90d', product: 'dashcaddy-pro' }, }, }, }; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(result.body.productId).toBe('pro-90d'); expect(result.body.durationDays).toBe(90); }); test('acks async_payment_failed events without generating a license', async () => { const event = { id: `evt_ach_fail_${crypto.randomBytes(6).toString('hex')}`, type: 'checkout.session.async_payment_failed', data: { object: { id: `cs_test_fail_${crypto.randomBytes(6).toString('hex')}`, payment_status: 'unpaid', }, }, }; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(false); expect(result.body.reason).toBe('async-payment-failed'); expect(licenseKeygenMock.__getGenerateCalls()).toBe(0); }); }); describe('stripe-license-bridge happy path', () => { test('generates + persists + delivers license (dev-console SMTP fallback)', async () => { const event = buildSessionEvent({ productId: 'pro-90d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(result.body.productId).toBe('pro-90d'); expect(result.body.durationDays).toBe(90); expect(result.body.codeId).toBeTruthy(); expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // Fulfillment record exists. const record = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }) .readBySession(event.data.object.id); expect(record.status).toBe('delivered'); expect(record.code).toBeTruthy(); expect(record.codeId).toBe(result.body.codeId); expect(record.deliveredVia).toBe('dev-console'); }); }); describe('stripe-license-bridge idempotency', () => { test('duplicate eventId (Stripe retry) returns 200 without regenerating', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const first = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(first.status).toBe(200); expect(first.body.delivered).toBe(true); const second = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(second.status).toBe(200); expect(second.body.deduplicated).toBe(true); // generateCodes called exactly once across both deliveries. expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); }); test('two events for the same session reuse the same license (layer-2 idempotency)', async () => { const sessionId = `cs_test_shared_${crypto.randomBytes(4).toString('hex')}`; const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId }); const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId }); const payloadA = buildSignedPayload(eventA); const payloadB = buildSignedPayload(eventB); const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader }); const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader }); expect(rA.status).toBe(200); expect(rA.body.delivered).toBe(true); // Second event hits layer-1 idempotency by eventId — different eventId, // so falls through to layer-2 by sessionId; sees existing delivered record. expect(rB.status).toBe(200); expect(rB.body.delivered).toBe(true); expect(rB.body.codeId).toBe(rA.body.codeId); // SAME license code expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // only one code generated }); }); describe('stripe-license-bridge SMTP failure recovery (DC-057 acceptance)', () => { beforeEach(() => { // Inject SMTP BEFORE each test so SMTP_HOST is set when deliverCode runs. injectSmtp(async () => { throw new Error('smtp-down'); }); process.env.SMTP_HOST = 'smtp.example.com'; process.env.SMTP_FROM = 'noreply@example.com'; }); test('SMTP failure persists license, returns 500, but customer can retrieve via lookup', async () => { const event = buildSessionEvent({ productId: 'pro-180d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(500); expect(result.body.reason).toBe('email-failed'); // License IS persisted (the documented SMTP-failure recovery path). const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const record = store.readBySession(event.data.object.id); expect(record.code).toBeTruthy(); expect(record.status).toBe('pending_email'); expect(record.lastError).toMatch(/smtp-down/); // The lookup endpoint serves the persisted code ANYWAY. const lookup = bridge.lookupSession(event.data.object.id); expect(lookup.status).toBe('pending_email'); expect(lookup.code).toBe(record.code); expect(lookup.durationDays).toBe(180); expect(lookup.productId).toBe('pro-180d'); }); test('Stripe retry after SMTP failure keeps retrying (customer recovers via lookup)', async () => { const event = buildSessionEvent({ productId: 'pro-365d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const first = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(first.status).toBe(500); // Stripe retries with the SAME eventId. SMTP is still down → bridge // keeps retrying (returns 500) until either SMTP recovers or Stripe // gives up. The customer recovery path is via the lookup endpoint — // the license IS persisted in the fulfillment store regardless. const retry = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(retry.status).toBe(500); const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const record = store.readBySession(event.data.object.id); expect(record.code).toBeTruthy(); expect(record.status).toBe('pending_email'); // Lookup serves the persisted code. const lookup = bridge.lookupSession(event.data.object.id); expect(lookup.code).toBe(record.code); // Only one license generated across the retries (layer-2 idempotency). expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); }); test('SMTP recovers on a subsequent attempt (different eventId, same session) — still reuses the persisted code', async () => { let smtpCalls = 0; injectSmtp(async () => { smtpCalls++; if (smtpCalls === 1) throw new Error('smtp-temp-down'); return { messageId: 'msg-ok' }; }); const sessionId = `cs_test_recover_${crypto.randomBytes(4).toString('hex')}`; const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId }); const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId }); const payloadA = buildSignedPayload(eventA); const payloadB = buildSignedPayload(eventB); const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader }); expect(rA.status).toBe(500); // first attempt: SMTP down expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // Read the persisted code from the store (rA.body doesn't include it on // failure — by design, we don't leak license material in error responses). const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const persistedCode = store.readBySession(sessionId).code; expect(persistedCode).toBeTruthy(); const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader }); expect(rB.status).toBe(200); // second event, same session: reuses persisted code, delivery succeeds expect(rB.body.delivered).toBe(true); // Same code reused, NOT a fresh generation. expect(rB.body.codeId).toBeTruthy(); // The store's codeId matches rB.body.codeId (proves reuse, not regeneration). expect(rB.body.codeId).toBe(store.readBySession(sessionId).codeId); // No new license generated. expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); expect(smtpCalls).toBe(2); }); }); describe('stripe-license-bridge lookupSession', () => { test('returns not_found for unknown sessionId', () => { expect(bridge.lookupSession('cs_unknown')).toEqual({ status: 'not_found' }); }); test('returns expired for record past TTL', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); await bridge.handleWebhook({ rawBody, signatureHeader }); // Far-future "now" past the 24h TTL. const future = Date.now() + 25 * 60 * 60 * 1000; const lookup = bridge.lookupSession(event.data.object.id, { nowMs: future }); expect(lookup.status).toBe('expired'); }); test('returns processing state for fresh claim without code', async () => { const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const sessionId = `cs_test_processing_${crypto.randomBytes(4).toString('hex')}`; await store.claim({ eventId: 'evt_pend', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' }); const lookup = bridge.lookupSession(sessionId); expect(lookup.status).toBe('processing'); expect(lookup.durationDays).toBe(30); expect(lookup.productId).toBe('pro-30d'); }); }); describe('stripe-license-bridge constants', () => { test('LOOKUP_TTL_MS defaults to 24h', () => { expect(bridge.LOOKUP_TTL_MS).toBe(24 * 60 * 60 * 1000); }); test('DELIVERY_LEASE_MS is exported', () => { expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0); }); }); describe('stripe-license-bridge invoice + email rendering (DC-058)', () => { // These tests verify the bridge actually invokes the invoice renderer // with the right inputs and that the SMTP send receives a multipart // body + a PDF attachment. Pairs with invoice.test.js (which tests the // rendering primitives in isolation). test('passes customerName, sessionId, and amount through to the renderer', async () => { const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; const event = buildSessionEvent({ productId: 'pro-90d', customerEmail: 'alice@example.com', }); // Add customer_details.name + amount_total in line_items[0] (like real Stripe). event.data.object.customer_details.name = 'Alice Johnson'; event.data.object.line_items = { data: [{ amount_total: 5000, price: { id: 'price_90d_test', unit_amount: 5000 }, currency: 'usd' }], }; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(result.body.deliveredVia).toBe('smtp'); // Verify the SMTP send was called with branded email + PDF attachment. expect(sendMailMock).toHaveBeenCalledTimes(1); const mailArgs = sendMailMock.mock.calls[0][0]; expect(mailArgs.from).toBe('billing@dashcaddy.test'); expect(mailArgs.to).toBe('alice@example.com'); // Subject contains duration and "invoice". expect(mailArgs.subject).toContain('DashCaddy Pro'); expect(mailArgs.subject).toContain('invoice'); // HTML + text both present (multipart/alternative). expect(mailArgs.text).toBeDefined(); expect(mailArgs.html).toBeDefined(); expect(mailArgs.html).toContain('Hi Alice'); // first name from customer_details.name expect(mailArgs.html).toContain('INV-'); // invoice number expect(mailArgs.html).toContain('$50.00'); // 90d tier price // PDF attachment present. expect(Array.isArray(mailArgs.attachments)).toBe(true); expect(mailArgs.attachments).toHaveLength(1); expect(mailArgs.attachments[0].filename).toMatch(/^DashCaddy-Pro-Invoice-INV-.+\.pdf$/); expect(mailArgs.attachments[0].contentType).toBe('application/pdf'); expect(mailArgs.attachments[0].encoding).toBe('base64'); expect(mailArgs.attachments[0].content.length).toBeGreaterThan(1000); // real PDF // PDF magic bytes. expect(mailArgs.attachments[0].content.slice(0, 4).toString('ascii')).toBe('%PDF'); }); test('falls back to catalog amount when line_items are missing', async () => { const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; const event = buildSessionEvent({ productId: 'pro-365d' }); // Strip line_items entirely (simulates a webhook without expansion). delete event.data.object.line_items; delete event.data.object.amount_total; // Strip customer_details.name to verify "Hi there," fallback. delete event.data.object.customer_details.name; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); const mailArgs = sendMailMock.mock.calls[0][0]; // Falls back to catalog: pro-365d is $99.00. expect(mailArgs.html).toContain('$99.00'); expect(mailArgs.html).toContain('Hi there,'); }); test('dev-console fallback logs invoice number + PDF size', async () => { const event = buildSessionEvent({ productId: 'pro-30d' }); event.data.object.customer_details.name = 'Bob'; const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.deliveredVia).toBe('dev-console'); // We can't easily assert on log output from here, but the status proves // the dev-console path was taken. The log line includes pdfBytes — // covered indirectly by invoice.test.js verifying the PDF size. }); test('uses claim createdAt as issuedAt (not now) for stable retry semantics', async () => { const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; const event = buildSessionEvent({ productId: 'pro-30d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); const mailArgs = sendMailMock.mock.calls[0][0]; // The "Issued" line must reflect the claim's createdAt (which is when // the customer paid), not the moment we sent the email. expect(mailArgs.html).toMatch(/Issued[\s\S]*?\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/); }); test('gracefully degrades to text-only email when PDF render fails', async () => { const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; // Force PDF render to throw by passing an invalid issuedAt — this // exercises the try/catch around renderInvoicePdf and verifies the // bridge still sends a text+HTML email without the attachment. // (PDFKit auto-escapes most non-ASCII; lone surrogates no longer // throw on this PDFKit version. Bad dates remain a real crash path.) const event = buildSessionEvent({ productId: 'pro-30d' }); // Override issuedAt to an invalid date via the bridge's deliverCode arg. // The bridge forwards this from the invoice module, which we can stub // at module level for this test. const invoiceMod = require('../../src/billing/invoice'); const originalRender = invoiceMod.renderInvoicePdf; invoiceMod.renderInvoicePdf = jest.fn().mockRejectedValue(new Error('simulated PDF render failure')); try { const { rawBody, signatureHeader } = buildSignedPayload(event); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(sendMailMock).toHaveBeenCalledTimes(1); const mailArgs = sendMailMock.mock.calls[0][0]; // No PDF attachment when render failed. expect(mailArgs.attachments).toBeUndefined(); // Text + HTML still sent (keygen mock uses DC-TEST-... in this suite). expect(mailArgs.text).toMatch(/DC-(PRO|TEST)-/); expect(mailArgs.html).toContain('DashCaddy'); } finally { invoiceMod.renderInvoicePdf = originalRender; } }); test('replay (layer-1 event-id idempotency) does NOT re-render the invoice', async () => { const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; const event = buildSessionEvent({ productId: 'pro-30d' }); const { rawBody, signatureHeader } = buildSignedPayload(event); const sessionId = event.data.object.id; // First delivery — generates a new license + invoice. const first = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(first.body.delivered).toBe(true); expect(first.body.codeId).toBeDefined(); const firstCodeId = first.body.codeId; expect(sendMailMock).toHaveBeenCalledTimes(1); // Second delivery of the SAME event — should be deduplicated by event id // at the layer-1 check (bridge.checkEventIdempotency). SMTP must NOT be // called again because Stripe retrying the same event ID should never // re-send the invoice. const second = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(second.body.delivered).toBe(true); expect(second.body.deduplicated).toBe(true); expect(sendMailMock).toHaveBeenCalledTimes(1); }); test('layer-2 (different event, same session) does NOT re-send the invoice', async () => { // Stripe can send BOTH `checkout.session.completed` AND // `checkout.session.async_payment_succeeded` for the same Checkout Session // (delayed-payment methods). Layer-1 dedup doesn't catch this because // the event IDs differ — only the session ID is the same. The bridge // MUST recognize that delivery already happened via the OTHER event and // ack 200 without re-sending. const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' }); injectSmtp(sendMailMock); process.env.SMTP_HOST = 'smtp.test'; process.env.SMTP_FROM = 'billing@dashcaddy.test'; const sessionId = `cs_test_layer2_${crypto.randomBytes(4).toString('hex')}`; const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId, eventId: `evt_A_${crypto.randomBytes(4).toString('hex')}`, }); eventA.type = 'checkout.session.completed'; const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId, eventId: `evt_B_${crypto.randomBytes(4).toString('hex')}`, }); eventB.type = 'checkout.session.async_payment_succeeded'; // First event: completes the payment, sends the invoice. const sigA = buildSignedPayload(eventA); const resultA = await bridge.handleWebhook({ rawBody: sigA.rawBody, signatureHeader: sigA.signatureHeader }); expect(resultA.status).toBe(200); expect(resultA.body.delivered).toBe(true); expect(resultA.body.deduplicated).toBeUndefined(); expect(sendMailMock).toHaveBeenCalledTimes(1); const firstInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename; // Second event for the SAME session: must NOT re-send (different event // id, so layer-1 dedup doesn't catch it; layer-2 must). const sigB = buildSignedPayload(eventB); const resultB = await bridge.handleWebhook({ rawBody: sigB.rawBody, signatureHeader: sigB.signatureHeader }); expect(resultB.status).toBe(200); expect(resultB.body.delivered).toBe(true); expect(resultB.body.deduplicated).toBe(true); // CRITICAL: only ONE SMTP call. Two invoice emails with different invoice // numbers for one charge is a financial-document bug. expect(sendMailMock).toHaveBeenCalledTimes(1); const secondInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename; expect(secondInvoice).toBe(firstInvoice); // same invoice number }); });