AuroraMeter.ExporterCase (Aurora Meter v1.0.0)

View Source

The conformance suite every AuroraMeter.Exporter should pass.

An exporter decides how usage becomes money, and the ways it can be subtly wrong are the expensive ones: a rate limit read as a rejection, a lost acknowledgement read as an acceptance, a batch that stops at its first problem. This suite is how an adapter author finds out before a customer does.

defmodule MyApp.ExporterConformanceTest do
  use AuroraMeter.ExporterCase, exporter: MyApp.Exporter, async: true

  def script(subject_ref, scenario), do: MyApp.Exporter.Fake.script(subject_ref, scenario)
  def sent(subject_ref), do: MyApp.Exporter.Fake.sent(subject_ref)
end

The three hooks

All optional, all defined in the test module itself.

setup_exporter/1 runs in setup and returns a map merged into the test context: start a fake, check out a connection, whatever the adapter needs.

script/2 takes a subject reference and a scenario, and makes the adapter answer that reference that way on its next delivery. Return :ok when the adapter can be made to do it and :unsupported when it cannot. The scenarios:

ScenarioMake the adapter behave as if
:acceptedthe provider took it
{:accepted, ref}the provider took it and named it
{:retry, seconds}the provider said "not now" and named a delay
:uncertainthe answer was lost after the request was sent
{:rejected, reason}the provider refused it permanently
:rate_limitedthe provider returned a rate limit (HTTP 429)
:malformedthe provider returned something the adapter could not parse
:unknown_statusthe provider returned a status the adapter does not know
:raisethe provider call blew up

sent/1 takes a subject reference and returns the payloads the adapter actually sent for it, oldest first. Without it the payload-identity area cannot be checked, because nothing else can see what left the process.

Areas that cannot be scripted are named, not hidden

Forcing every adapter author to fake a 429 would make this suite unusable, so an area whose scenario script/2 reports :unsupported does not fail: the test passes and the area is recorded. One test then always runs and names every area that was not covered, through IO.warn/1, so "the suite passed" never quietly means "the suite did nothing".

Pass require_scriptable: true to turn that warning into a failure. An adapter that talks to a real provider should: the whole point of a fake is that it can be made to misbehave.

Reading a failure

Every assertion is a public function of this module and every generated test is one line, so a failure names a function you can open rather than a line inside a macro expansion. That also makes the suite testable: a deliberately wrong adapter can be handed straight to assert_identity!/1 and the assertion proved to reject it, which is the only way to know the suite has ever rejected anything.

What it does not do

It does not start your adapter, it does not know your provider, and it makes no network call of its own. It asserts the contract in AuroraMeter.Exporter and nothing else.

Summary

Functions

The contract areas this suite checks.

Names every area this suite could not exercise, and fails instead of warning when the case was used with require_scriptable: true.

E4. describe/0 returns the documented map, with numbers that make sense.

E4. describe/0 is constant.

E3. A provider call that blows up leaves every item in that call :uncertain.

E1. Three items in, three outcomes out, keyed by the ids that went in.

E2. An answer the adapter could not parse is :uncertain.

E1. One batch, two members, two different answers.

E5, and core's half of invariant I15. Delivering the same item twice sends the same bytes.

E2. A rate limit is a retry.

E2. A scripted retry arrives as {:retry, delay} or {:retry, nil}, and as nothing else.

E2. A provider status nobody wrote a clause for is :uncertain.

Calls the adapter the way a correct caller must, and returns {raw_answer, normalized}.

Builds count items for this suite, with a subject reference nothing else will collide with and a subject kind the adapter says it supports.

The scenarios script/2 may be asked for.

Asks the suite's script/2 for one scenario, returning :ok or :unsupported.

Builds the test context, calling the suite's setup_exporter/1 when it has one. Returns {:ok, context} the way an ExUnit setup expects.

Functions

areas()

@spec areas() :: [atom()]

The contract areas this suite checks.

assert_coverage!(ctx)

@spec assert_coverage!(map()) :: :ok

Names every area this suite could not exercise, and fails instead of warning when the case was used with require_scriptable: true.

It probes the hooks rather than depending on the other tests having run: ExUnit orders tests by seed, so a report that counted what earlier tests had recorded would report differently on different seeds.

assert_description!(ctx)

@spec assert_description!(map()) :: :ok

E4. describe/0 returns the documented map, with numbers that make sense.

assert_description_constant!(ctx)

@spec assert_description_constant!(map()) :: :ok

E4. describe/0 is constant.

The caller reads it once and clamps its batch size to it. An adapter whose description moves has made that clamp a race.

assert_exceptions!(ctx)

@spec assert_exceptions!(map()) :: :ok | {:unscripted, atom()}

E3. A provider call that blows up leaves every item in that call :uncertain.

An adapter should not raise for a provider problem, and if it does the caller must survive it: call/2 here catches a raise, an exit and a throw alike, because an adapter that exits is exactly as fatal to a caller that only rescues as one that raises is to a caller that does neither.

The failure this catches is the opposite temptation: an adapter that catches its own blow-up and reports :accepted so the batch looks clean.

assert_identity!(ctx)

@spec assert_identity!(map()) :: :ok | {:unscripted, atom()}

E1. Three items in, three outcomes out, keyed by the ids that went in.

This is the assertion an adapter breaks by dropping a member of a batch, by answering for something it was not given, and by abandoning the return type altogether. All three are the same defect from the caller's side: it cannot tell what happened to an item it handed over.

assert_malformed_response!(ctx)

@spec assert_malformed_response!(map()) :: :ok | {:unscripted, atom()}

E2. An answer the adapter could not parse is :uncertain.

Not :accepted, which would tell the caller the money arrived, and not {:rejected, _}, which would tell it to stop. An unparseable answer is exactly the case where the request may have been executed.

assert_partial_success!(ctx)

@spec assert_partial_success!(map()) :: :ok | {:unscripted, atom()}

E1. One batch, two members, two different answers.

The defect this catches is an adapter that reduces over the batch and halts on the first member that is not accepted. The later members are then not merely unknown, they were never attempted, and the caller has no way to tell the difference.

assert_payload_identity!(ctx)

@spec assert_payload_identity!(map()) :: :ok | {:unscripted, atom()}

E5, and core's half of invariant I15. Delivering the same item twice sends the same bytes.

The payload is the financial intent. An adapter that re-derives a field from live application state has made the retry send something the first attempt did not, under the same idempotency key, which is how one unit of usage becomes two charges.

Needs the suite's sent/1; without it nothing can see what left the process, and the area is recorded as uncovered rather than quietly skipped.

assert_rate_limiting!(ctx)

@spec assert_rate_limiting!(map()) :: :ok | {:unscripted, atom()}

E2. A rate limit is a retry.

This is the direct regression for the defect the outcome vocabulary exists to fix: one provider answer classified two ways by two functions in one package, with HTTP 429 terminal on the path that reports usage. A terminal rate limit either abandons revenue or, if the caller re-derives an identity for the next attempt, bills it twice.

assert_retry!(ctx)

@spec assert_retry!(map()) :: :ok | {:unscripted, atom()}

E2. A scripted retry arrives as {:retry, delay} or {:retry, nil}, and as nothing else.

A bare :retry, an {:error, :rate_limited} or any other invention is read by AuroraMeter.Exporter.normalize/2 as :uncertain, which costs the caller a reconciliation for something that was only ever a delay.

assert_unknown_outcome!(ctx)

@spec assert_unknown_outcome!(map()) :: :ok | {:unscripted, atom()}

E2. A provider status nobody wrote a clause for is :uncertain.

An adapter that falls through to :accepted has turned "I do not know" into "the money arrived", and an adapter that falls through to {:rejected, _} has turned it into "give up".

call(ctx, items)

@spec call(map(), [AuroraMeter.Exporter.Item.t()]) ::
  {term(),
   {:ok, %{optional(String.t()) => AuroraMeter.Exporter.outcome()}}
   | {:error, term()}}

Calls the adapter the way a correct caller must, and returns {raw_answer, normalized}.

The guard is the point. A caller that lets an adapter's raise, exit or throw escape has lost the outcome of every item in the call rather than recording them as uncertain, and the difference between the three is only which construct catches them. Build unit 04b's deliverer owes the same guard, and the telemetry event [:aurora_meter, :exporter, :exception] beside it.

items(ctx, count)

@spec items(map(), pos_integer()) :: [AuroraMeter.Exporter.Item.t()]

Builds count items for this suite, with a subject reference nothing else will collide with and a subject kind the adapter says it supports.

scenarios()

@spec scenarios() :: [term()]

The scenarios script/2 may be asked for.

script!(ctx, subject_ref, scenario)

@spec script!(map(), String.t(), term()) :: :ok | :unsupported

Asks the suite's script/2 for one scenario, returning :ok or :unsupported.

setup_case(config, context)

@spec setup_case(map(), map()) :: {:ok, keyword()}

Builds the test context, calling the suite's setup_exporter/1 when it has one. Returns {:ok, context} the way an ExUnit setup expects.