LatticeStripe.Testing.TestClock (LatticeStripe v1.7.12)

Copy Markdown View Source

ExUnit ergonomics for Stripe Test Clocks.

This module provides a use-macro that users opt into from their own ExUnit.CaseTemplate. It wraps LatticeStripe.TestHelpers.TestClock with automatic per-test cleanup (via a lightweight GenServer Owner), automatic customer-to-clock linkage (closing the silent-correctness footgun of forgetting test_clock: on Customer.create/2), and a human-friendly advance/2 that takes time units.

Usage

defmodule MyApp.StripeCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use LatticeStripe.Testing.TestClock, client: MyApp.StripeClient
    end
  end
end

defmodule MyApp.BillingTest do
  use MyApp.StripeCase, async: true
  setup :with_test_clock

  test "sub renews after 30 days", %{test_clock: clock} do
    customer = create_customer(clock, email: "a@b.c")
    {:ok, sub} = MyApp.Billing.subscribe(customer, "price_monthly")
    advance(clock, days: 30)
    assert {:ok, %{status: "active"}} = MyApp.Billing.get_subscription(sub.id)
  end
end

Client contract

The module passed as :client must be either:

  • A %LatticeStripe.Client{} struct directly, or
  • A module atom that exposes a public stripe_client/0 function returning a %LatticeStripe.Client{}.

Every helper also accepts a per-call :client option (a %Client{} struct) that wins over the compile-time binding. This supports multi-account tests.

Cleanup strategy

Every clock created via test_clock/1 is registered with a per-test Owner GenServer (internal, not part of the public API). On test exit (including crash / assertion failure), the Owner deletes each registered clock via LatticeStripe.TestHelpers.TestClock.delete/3. Stripe's delete cascades to attached Customers and Subscriptions.

For SIGKILL / BEAM crash / CI timeout scenarios that bypass on_exit, the mix lattice_stripe.test_clock.cleanup task backstops by deleting test clocks older than a configurable threshold.

Metadata marker (A-13g caveat)

Stripe's Test Clock API does not support metadata on create (verified via OpenAPI spec and stripe-mock on 2026-04-11). This means the Mix task cleanup backstop uses age-based filtering only and cannot distinguish LatticeStripe-managed clocks from user-created ones. The primary cleanup path (Owner + on_exit) is unaffected.

If Stripe adds metadata support in the future, this module will be updated to tag clocks with a marker for precise Mix task filtering.

Supported advance units (v1)

advance/2 accepts: :seconds, :minutes, :hours, :days, or :to (absolute DateTime). Passing :months or :years raises ArgumentError -- Elixir 1.15 (the project minimum) has no calendar shift helper, and month-length arithmetic is fiddly. For month/year advancement, use:

advance(clock, to: DateTime.utc_now() |> DateTime.add(86_400 * 30, :second))

Customer-to-clock linkage (D-13h)

create_customer/2,3 auto-injects test_clock: clock.id into the customer creation params. This closes the silent-correctness footgun where forgetting test_clock: means the customer runs on real time and clock advances have no effect. Users who bypass this wrapper and call LatticeStripe.Customer.create/2 directly are responsible for injecting the test_clock param themselves.

Summary

Functions

Advances a test clock by a given unit, waiting until :ready.

Creates a customer attached to the given test clock.

Waits for a test clock to reach :ready at its current frozen_time. Useful after out-of-band clock ops.

Creates a test clock and registers it for automatic cleanup.

ExUnit setup callback. Creates a test clock and injects it into the context as :test_clock.

Functions

advance(clock, unit_opts)

Advances a test clock by a given unit, waiting until :ready.

Supported units (v1)

  • [seconds: N]
  • [minutes: N]
  • [hours: N]
  • [days: N]
  • [to: %DateTime{}] -- absolute target

[months: N] and [years: N] raise ArgumentError -- Elixir 1.15 has no calendar shift helper. Use [to: DateTime] with hand-computed month math if needed.

Options

  • :client -- per-call client override (%LatticeStripe.Client{}). When omitted, falls back to the process-bound client from the use macro.

Example

advance(clock, days: 30)
advance(clock, days: 30, client: my_client)

create_customer(clock, params \\ %{}, opts \\ [])

Creates a customer attached to the given test clock.

Wraps LatticeStripe.Customer.create/2 and auto-injects test_clock: clock.id into the params. This closes the D-13h silent-correctness footgun of forgetting test_clock: and having the customer run on real time.

Example

customer = create_customer(clock, email: "a@b.c")

freeze(clock, opts \\ [])

Waits for a test clock to reach :ready at its current frozen_time. Useful after out-of-band clock ops.

test_clock(opts \\ [])

Creates a test clock and registers it for automatic cleanup.

Options

  • :frozen_time -- unix timestamp integer (default: current system time)
  • :name -- human-readable name (default: "lattice_stripe_test")
  • :client -- per-call client override (%LatticeStripe.Client{})

Example

clock = test_clock(frozen_time: ~U[2026-01-01 00:00:00Z] |> DateTime.to_unix())

with_test_clock(context)

ExUnit setup callback. Creates a test clock and injects it into the context as :test_clock.

Usage

setup :with_test_clock

test "sub renews", %{test_clock: clock} do
  ...
end