From 49f6019798d06c15a8d602955bbe307a1ab0b585 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 27 Jan 2026 13:32:52 +0000 Subject: [PATCH] chore: add email reciept unit tests --- .../invoice-paid/send-email-receipt.spec.ts | 262 ++++++++++++++++++ server/tests/utils/fixtures/db/customers.ts | 1 + server/tests/utils/fixtures/stripe/clients.ts | 56 ++++ 3 files changed, 319 insertions(+) create mode 100644 server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts create mode 100644 server/tests/utils/fixtures/stripe/clients.ts diff --git a/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts new file mode 100644 index 000000000..d1eb44894 --- /dev/null +++ b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts @@ -0,0 +1,262 @@ +/** + * Unit tests for sendEmailReceipt function. + * + * Tests the logic that sets receipt_email on PaymentIntent + * based on customer's should_send_email_receipts flag. + */ + +import { describe, expect, test } from "bun:test"; +import { AppEnv, type FullCustomer } from "@autumn/shared"; +import { stripeClients } from "@tests/utils/fixtures/stripe/clients"; +import chalk from "chalk"; +import type { StripeInvoicePaidContext } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext"; +import { sendEmailReceipt } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt"; +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; + +// ============ MOCK HELPERS ============ + +const createMockLogger = () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, +}); + +const createMockFullCustomer = ( + overrides: Partial = {}, +): FullCustomer => ({ + id: "cus_test", + internal_id: "cus_internal_test", + name: "Test Customer", + email: "test@example.com", + fingerprint: null, + org_id: "org_test", + created_at: Date.now(), + env: AppEnv.Sandbox, + processor: { type: "stripe", id: "cus_stripe_test" }, + processors: null, + metadata: {}, + customer_products: [], + entities: [], + extra_customer_entitlements: [], + should_send_email_receipts: true, + ...overrides, +}); + +const createMockInvoicePaidContext = ( + paymentIntentId?: string, +): StripeInvoicePaidContext => + ({ + stripeInvoice: { + id: "inv_test", + payments: paymentIntentId + ? { + data: [ + { + payment: { payment_intent: paymentIntentId }, + }, + ], + } + : { data: [] }, + }, + }) as unknown as StripeInvoicePaidContext; + +// ============ TESTS ============ + +describe(chalk.yellowBright("sendEmailReceipt"), () => { + describe(chalk.cyan("Early returns - no Stripe API calls"), () => { + test("returns when no fullCustomer", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: undefined, + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("returns when should_send_email_receipts is false", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer({ + should_send_email_receipts: false, + }), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("returns when should_send_email_receipts is undefined", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer({ + should_send_email_receipts: undefined as unknown as boolean, + }), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("returns when customer has no email", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer({ + email: undefined as unknown as string, + }), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("returns when customer has empty string email", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer({ email: "" }), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("returns when invoice has no payments", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient(); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer(), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext(undefined), + }); + + expect(mockCli._calls.retrieve).toHaveLength(0); + expect(mockCli._calls.update).toHaveLength(0); + }); + }); + + describe(chalk.cyan("PaymentIntent already has receipt_email"), () => { + test("returns without updating when receipt_email already set", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient({ + retrieveResult: { receipt_email: "existing@example.com" }, + }); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer(), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(1); + expect(mockCli._calls.update).toHaveLength(0); + }); + }); + + describe(chalk.cyan("Success - sets receipt_email"), () => { + test("updates PaymentIntent with customer email when all conditions met", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient({ + retrieveResult: { receipt_email: null }, + }); + const customerEmail = "customer@example.com"; + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer({ email: customerEmail }), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(1); + expect(mockCli._calls.retrieve[0]).toBe("pi_123"); + expect(mockCli._calls.update).toHaveLength(1); + expect(mockCli._calls.update[0].id).toBe("pi_123"); + expect(mockCli._calls.update[0].params.receipt_email).toBe(customerEmail); + }); + }); + + describe(chalk.cyan("Error handling"), () => { + test("does not throw when paymentIntents.retrieve fails", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient({ + retrieveError: new Error("Stripe API error"), + }); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer(), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(1); + expect(mockCli._calls.update).toHaveLength(0); + }); + + test("does not throw when paymentIntents.update fails", async () => { + const mockCli = stripeClients.createMockPaymentIntentsClient({ + retrieveResult: { receipt_email: null }, + updateError: new Error("Stripe API error"), + }); + const ctx = { + stripeCli: mockCli, + logger: createMockLogger(), + fullCustomer: createMockFullCustomer(), + } as unknown as StripeWebhookContext; + + await sendEmailReceipt({ + ctx, + invoicePaidContext: createMockInvoicePaidContext("pi_123"), + }); + + expect(mockCli._calls.retrieve).toHaveLength(1); + expect(mockCli._calls.update).toHaveLength(1); + }); + }); +}); diff --git a/server/tests/utils/fixtures/db/customers.ts b/server/tests/utils/fixtures/db/customers.ts index 722971519..930eb1644 100644 --- a/server/tests/utils/fixtures/db/customers.ts +++ b/server/tests/utils/fixtures/db/customers.ts @@ -23,6 +23,7 @@ const create = ({ customer_products: customerProducts, entities: [], extra_customer_entitlements: [], + should_send_email_receipts: false, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/fixtures/stripe/clients.ts b/server/tests/utils/fixtures/stripe/clients.ts new file mode 100644 index 000000000..bb69385a4 --- /dev/null +++ b/server/tests/utils/fixtures/stripe/clients.ts @@ -0,0 +1,56 @@ +/** + * Mock Stripe client fixtures for unit testing. + * + * Provides configurable mock implementations of Stripe API methods + * that can be used to test webhook handlers and other Stripe-dependent code. + */ + +import type Stripe from "stripe"; + +/** + * Create a mock Stripe client with configurable paymentIntents methods + */ +const createMockPaymentIntentsClient = ({ + retrieveResult = { receipt_email: null } as Partial, + updateResult = {} as Partial, + retrieveError, + updateError, +}: { + retrieveResult?: Partial; + updateResult?: Partial; + retrieveError?: Error; + updateError?: Error; +} = {}) => { + const retrieveCalls: string[] = []; + const updateCalls: { + id: string; + params: Stripe.PaymentIntentUpdateParams; + }[] = []; + + return { + paymentIntents: { + retrieve: async (id: string) => { + retrieveCalls.push(id); + if (retrieveError) throw retrieveError; + return retrieveResult as Stripe.PaymentIntent; + }, + update: async (id: string, params: Stripe.PaymentIntentUpdateParams) => { + updateCalls.push({ id, params }); + if (updateError) throw updateError; + return updateResult as Stripe.PaymentIntent; + }, + }, + _calls: { + retrieve: retrieveCalls, + update: updateCalls, + }, + }; +}; + +// ═══════════════════════════════════════════════════════════════════ +// EXPORT +// ═══════════════════════════════════════════════════════════════════ + +export const stripeClients = { + createMockPaymentIntentsClient, +} as const;