OapiCodemode.SpecStoreCase (oapi_codemode v0.5.0)

Copy Markdown View Source

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}
end

The 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}
end

Put 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/2 from spawned processes. A store needing per-process setup — an Ecto sandbox connection — should run the suite without async: true and put the sandbox in shared mode in store_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/2 race. Under a shared Ecto sandbox 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. An Ecto store 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/2 is the only writer and it always writes both. A store that can end up in that state — a migration that drops projections, an index_bytes backfill — 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

bumped(decomposed, dropped_op_id)

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.

decomposed()

@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).

reordered_spec()

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

The same document with every object's keys reversed, and reformatted — same hash.

spec()

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

The fixture document, as raw JSON.

unknown_spec_refs()

@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.