The OapiCodemode.SpecStore conformance suite.
A store is only interchangeable if the contract is tested once and run
against every implementation, so the tests live here — in lib/, not
test/ — and downstream repos run the same file against their own store:
defmodule MyApp.SpecStore.EctoConformanceTest do
use OapiCodemode.SpecStoreCase, async: true
defp store_fixture, do: {MyApp.SpecStore.Ecto, MyApp.Repo}
endThe using module supplies store_fixture/0, returning an empty
{module, handle} store. It is called inside each test, so a store that
needs per-test setup (a checked-out sandbox connection, a fresh table)
can build it there.
This module brings ExUnit.Case in with the options passed to use. A
store whose repo has its own case module can compose the two — the outer
template's setup still runs, and its helpers are still in scope:
defmodule MyApp.SpecStore.EctoConformanceTest do
use MyApp.DataCase, async: false
use OapiCodemode.SpecStoreCase
defp store_fixture, do: {MyApp.SpecStore.Ecto, MyApp.Repo}
endPut the ExUnit.Case options (async:) on the outer use, which is the
one that registers the case; the inner use then joins it.
Two things the suite asks of an implementation that are easy to miss:
- A spec id it never issued must come back
{:error, :not_found}, not raise — even one shaped nothing like its own ids. Refs outlive the thing that made them (a registry entry survives a DB restore), so a store that casts its ids needs to fail the cast softly. - The concurrency case calls
put/2from spawned processes. A store needing per-process setup — anEctosandbox connection — should run the suite withoutasync: trueand put the sandbox in shared mode instore_fixture/0.
What is covered, from the design's testing section: the
decompose → put → meta/index/operation/components round-trip, put of
a known hash, a bumped decomposer_version coexisting with the old
projection, concurrent put of the same hash, pointer/4's byte budget
and its array walk, missing component names absent rather than fatal,
:not_found for an unknown ref or operation id, and the one filtering
rule a store must not apply itself:
Decompose owns the parameter filter
The fixture's listWidgets declares two parameter $refs, one of
which dangles. OapiCodemode.Ingest.Normalize drops the unreadable
identity when it builds the row, so the param_index a store is
handed is already what the slim index publishes:
OapiCodemode.Ingest.index_entry/1 is a pure projection of the row.
A store persists param_index and hands it back — select … , param_index is conformant, and a store that instead re-derives the
list from parameters (which keeps both refs verbatim) fails the
index test here rather than surprising a caller downstream.
What is not covered here, and has to be covered by the store:
- A real
put/2race. Under a sharedEctosandbox the eight concurrent puts all borrow one connection, so they serialise and the second putter reads the first one's committed rows. That checks the idempotent path; it does not reach the unique-hash-violation-then- re-read path, which needs genuinely simultaneous transactions —Ecto.Adapters.SQL.Sandbox.unboxed_run/2, or per-task connection ownership. AnEctostore owes its own race test. - Stale-projection rebuild. "The spec is there but the projection
is missing, so build it on demand" is unreachable through this
surface:
put/2is the only writer and it always writes both. A store that can end up in that state — a migration that drops projections, anindex_bytesbackfill — has to reach behind its own API to set it up, so the test belongs with the store.
Summary
Functions
A %Decomposed{} as a bumped decomposer would have produced it.
The suite's fixture document, decomposed.
The same document with every object's keys reversed, and reformatted — same hash.
The fixture document, as raw JSON.
Refs no store can hold, in two shapes.
Functions
@spec bumped(OapiCodemode.Decomposed.t(), String.t()) :: OapiCodemode.Decomposed.t()
A %Decomposed{} as a bumped decomposer would have produced it.
decomposer_version is a module attribute, so a version bump cannot be
simulated without recompiling; rebuilding the struct is the same thing as
far as a store can tell. The interpretation differs too — one operation
fewer — so that the two projections are distinguishable on read.
@spec decomposed() :: OapiCodemode.Decomposed.t()
The suite's fixture document, decomposed.
Small but not simple: a referenced parameter (so param_index has to be
identity-resolved) beside a parameter $ref that dangles (so the index a
store hands back is the filtered one), a requestBody written as a
Reference Object, a second
requestBody whose media type object is a Reference Object (the two
shapes a store has to keep verbatim rather than extract from), refs into
components from a response, a document-level security requirement and a
resolved security scheme, an array to walk under paths, one operation
carrying every coerced scalar field, a bare-boolean schema (legal in 3.1, so
a store has to hold it) and a null schema (which decompose drops, so no
store ever sees it).
@spec reordered_spec() :: String.t()
The same document with every object's keys reversed, and reformatted — same hash.
@spec spec() :: String.t()
The fixture document, as raw JSON.
@spec unknown_spec_refs() :: [OapiCodemode.SpecStore.ref()]
Refs no store can hold, in two shapes.
The first is hash-shaped — a document nobody put, which is what a stale
registry entry looks like. The second is nothing like an id any store
issues, because a ref arrives from outside and a store that casts its ids
has to fail the cast softly: {:error, :not_found}, not an
Ecto.Query.CastError from the query planner.