AuroraMeter.Test (Aurora Meter v1.0.0)

View Source

Helpers for testing an application that uses Aurora Meter.

defmodule MyApp.MeterCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use AuroraMeter.Test, reset: true
    end
  end
end

use AuroraMeter.Test imports every function below. With reset: true the ETS tables are cleared before each test (see reset!/0, which requires async: false); with sandbox: true an Ecto.Adapters.SQL.Sandbox owner is started on the configured repo for tests that do not already have one.

Set large :flush_interval / :broadcast_interval values in test config so the timers never fire mid-test, and drive them with flush!/0 and broadcast!/0.

Cross-node behaviour can be exercised on one node: simulate_node/3 applies deltas as if another node had gossiped them, and simulate_flush/2 applies totals as if another node had flushed.

For the credit ledger, fund!/3 and drain!/1 put a tenant at a known balance without inventing references, and credit_balance/1 reads it back.

To make a time-dependent behaviour deterministic, freeze the clock with with_clock/2 and move it with travel/1 and travel/2. The fixed clock is a single named agent, so it is global to the node and every test that uses it must be async: false.

Summary

Functions

Broadcasts touched counters to subscribed LiveViews (and gossips deltas) now.

Starts an Ecto.Adapters.SQL.Sandbox owner on the configured repo (shared unless the test is async) and stops it on exit. Use only when your own case template does not already do this.

The tenant's credit balance snapshot; see AuroraMeter.Credits.balance/1.

Debits everything tenant has available (nothing when the available balance is zero or negative), returning the amount drained.

Flushes dirty counters to the database now; returns the number of keys persisted.

Grants amount micro-dollars to tenant as an :adjustment with a unique reference (so it never collides with an earlier grant), returning the entry. Any AuroraMeter.Credits.grant/3 option can be overridden in opts.

Clears every Aurora Meter ETS table: counters, dirty and touched sets, and the subscription cache. Unflushed usage is discarded; the database is not touched. Because the tables are global, only use this in async: false tests (or give each test a unique tenant instead, see unique_tenant/1).

Applies totals ([{tenant, feature, total}]) as if node origin had flushed and announced them: this node re-bases each warm counter on the total plus its own unflushed increments.

Applies deltas ([{tenant, feature, delta}]) as if node origin had gossiped them: this node's view of each counter moves by the delta, exactly as it would in a cluster. period_start defaults to the tenant's current period. Only warm counters are affected, as in production; read the counter first if you need it seeded.

Subscribes tenant to plan with the assignment starting at since, and drops the cached row.

Moves the frozen clock to instant. Raises outside a with_clock/2 block.

Moves the frozen clock by amount of unit (any unit DateTime.add/3 accepts). Raises outside a with_clock/2 block, so it cannot silently no-op.

A process-unique tenant key, so tests can share the global ETS tables safely.

Runs fun with the library's clock frozen at instant.

Functions

broadcast!()

@spec broadcast!() :: :ok

Broadcasts touched counters to subscribed LiveViews (and gossips deltas) now.

checkout(tags \\ %{})

@spec checkout(map()) :: :ok

Starts an Ecto.Adapters.SQL.Sandbox owner on the configured repo (shared unless the test is async) and stops it on exit. Use only when your own case template does not already do this.

credit_balance(tenant)

@spec credit_balance(term()) :: AuroraMeter.Credits.balance()

The tenant's credit balance snapshot; see AuroraMeter.Credits.balance/1.

drain!(tenant)

@spec drain!(term()) :: non_neg_integer()

Debits everything tenant has available (nothing when the available balance is zero or negative), returning the amount drained.

flush!()

@spec flush!() :: non_neg_integer()

Flushes dirty counters to the database now; returns the number of keys persisted.

fund!(tenant, amount, opts \\ [])

@spec fund!(term(), pos_integer(), keyword()) :: AuroraMeter.Credits.txn()

Grants amount micro-dollars to tenant as an :adjustment with a unique reference (so it never collides with an earlier grant), returning the entry. Any AuroraMeter.Credits.grant/3 option can be overridden in opts.

fund!(org, Money.from_cents(1_000))
fund!(org, 500_000, category: :promotional, expires_at: tomorrow)

reset!()

@spec reset!() :: :ok

Clears every Aurora Meter ETS table: counters, dirty and touched sets, and the subscription cache. Unflushed usage is discarded; the database is not touched. Because the tables are global, only use this in async: false tests (or give each test a unique tenant instead, see unique_tenant/1).

simulate_flush(origin, totals, period_start \\ nil)

@spec simulate_flush(node(), [{term(), atom(), integer()}], DateTime.t() | nil) :: :ok

Applies totals ([{tenant, feature, total}]) as if node origin had flushed and announced them: this node re-bases each warm counter on the total plus its own unflushed increments.

simulate_node(origin, deltas, period_start \\ nil)

@spec simulate_node(node(), [{term(), atom(), integer()}], DateTime.t() | nil) :: :ok

Applies deltas ([{tenant, feature, delta}]) as if node origin had gossiped them: this node's view of each counter moves by the delta, exactly as it would in a cluster. period_start defaults to the tenant's current period. Only warm counters are affected, as in production; read the counter first if you need it seeded.

subscribe_since!(tenant, plan, since, opts \\ [])

@spec subscribe_since!(term(), atom(), DateTime.t(), keyword()) ::
  AuroraMeter.Schema.Subscription.t()

Subscribes tenant to plan with the assignment starting at since, and drops the cached row.

AuroraMeter.subscribe/3 starts the assignment at the database's clock, which is right for a real subscription and wrong for a test whose usage is dated in the past: every such event would be attributed before the contract existed and stamped attribution: :plan_unresolved (build unit 07c). This writes the row with an explicit plan_effective_at instead, which is a supported field of AuroraMeter.Storage.put_subscription/1, so a test about something other than attribution can have the tenant a real host would have.

Options: :version (default the effective one) and :status (default "active").

subscribe_since!("org_1", :pro, ~U[2026-01-01 00:00:00Z])

travel(instant)

@spec travel(DateTime.t()) :: :ok

Moves the frozen clock to instant. Raises outside a with_clock/2 block.

travel(amount, unit)

@spec travel(integer(), AuroraMeter.Clock.Fixed.unit()) :: :ok

Moves the frozen clock by amount of unit (any unit DateTime.add/3 accepts). Raises outside a with_clock/2 block, so it cannot silently no-op.

unique_tenant(prefix \\ "org")

@spec unique_tenant(String.t()) :: String.t()

A process-unique tenant key, so tests can share the global ETS tables safely.

with_clock(instant, fun)

@spec with_clock(DateTime.t(), (-> result)) :: result when result: var

Runs fun with the library's clock frozen at instant.

Installs AuroraMeter.Clock.Fixed under the clock: key, starts it at instant, runs fun, then stops the agent and restores the previous configuration. Restoration and the stop both run on a raise as well as on a normal return, so one failing test cannot leave the next one frozen.

Inside the block, move the clock with travel/1 and travel/2.

with_clock(~U[2026-01-31 23:59:59.999999Z], fn ->
  assert AuroraMeter.period(org).start == ~U[2026-01-01 00:00:00Z]
  travel(1, :microsecond)
  assert AuroraMeter.period(org).start == ~U[2026-02-01 00:00:00Z]
end)

The fixed clock is global to the node: use it only in async: false tests. Because a frozen clock stamps every row with the same inserted_at, a test that writes several credit ledger rows and then pages AuroraMeter.Credits.history/2 must travel/2 between the writes; see docs/testing.md.