textmachine/platform/internal/gates/contract_test.go

190 lines
8.1 KiB
Go

package gates
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"textmachine/platform/internal/httpapi"
)
// canonPath is the ratified API contract, from this package's directory. It is outside the zone on
// purpose: the contract belongs to `docs/` and this zone only serves it.
const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"
// The constant a deployment announces itself with must be the version the ratified canon carries.
//
// Every other test of this constant compares the WIRE to the constant, which is self-consistent and
// passes at any value — the class PD-1 names. This is the only check with an independent second
// source, and it is the reason the constant went two ratifications without moving: `0.3.0` was
// served while `0.4.0` was the canon, so a client generated against what the deployment ANNOUNCES
// would have refused the shapes it actually returns (while the major is `0`, a differing minor
// carries breaking changes by design — canon §Versioning).
//
// It reads the canon rather than a copy of the number: a gate that holds a second copy of the fact
// is one more place to forget.
func TestTheAnnouncedContractVersionIsTheOneTheCanonRatified(t *testing.T) {
src, err := os.ReadFile(filepath.Join(canonPath))
if err != nil {
// Not skipped. A missing canon leaves this constant with no check at all except a human
// noticing, which is exactly what failed twice.
t.Fatalf("the ratified canon could not be read, so the served contract version has nothing to be checked against: %v", err)
}
// `info.version` and not any other `version:` in the document: the file carries several.
m := regexp.MustCompile(`(?m)^info:\n(?:[ \t]+.*\n)*?[ \t]+version: (\S+)`).FindSubmatch(src)
if m == nil {
t.Fatal("the canon carries no info.version: this gate reads the wrong document or the wrong shape")
}
if got, want := httpapi.ContractVersion, string(m[1]); got != want {
t.Errorf("this build announces contract %s and the ratified canon is %s; the constant is raised in the same change as the code that implements a minor, never afterwards", got, want)
}
}
// The `blocked` vocabulary this deployment serves must be the one the canon enumerates.
//
// ⛔ IT IS THE SECOND CHECK IN THIS ZONE WITH AN INDEPENDENT SOURCE, and it exists because the first
// one — the version number above — cannot see a SHAPE. Every other test of these values compares the
// wire to the same Go constant it was rendered from, which is self-consistent and passes at any
// value: rename both of them and the whole module stays green (measured by the pack's own adversarial
// pass, 11.09). That is the class PD-1 names, and `Blocked.code` is the one place it costs a
// contract: the canon declares a CLOSED enum there, so a value this build serves and the canon does
// not is a client generated against the canon meeting a word its stubs do not carry.
//
// It reads the canon rather than a copy of it, like its neighbour, and it asserts BOTH directions:
// a value served and not ratified is a wire ahead of the contract, a value ratified and not served
// is a contract ahead of the wire — and which of the two is a defect depends on the day, so the gate
// names what it saw instead of guessing.
func TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates(t *testing.T) {
src, err := os.ReadFile(filepath.Join(canonPath))
if err != nil {
t.Fatalf("the ratified canon could not be read, so the served `blocked` vocabulary has nothing to be checked against: %v", err)
}
ratified, err := enumOfSchema(string(src), "Blocked")
if err != nil {
t.Fatalf("the canon's Blocked schema: %v — this gate reads the wrong document or the wrong shape", err)
}
// What this build can put in that member, written out rather than derived: a list a test walks is
// a list somebody has to extend deliberately, which is the whole point of a closed vocabulary.
served := map[string]bool{
httpapi.CauseCreditHeld: true,
httpapi.CauseRunInFlight: true,
}
for v := range served {
if !ratified[v] {
t.Errorf("this build serves `blocked.code: %s` and the canon's enum does not carry it: %v", v, keys(ratified))
}
}
for v := range ratified {
if !served[v] {
t.Errorf("the canon ratifies `blocked.code: %s` and this build never serves it: the wire is behind the contract", v)
}
}
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// enumOfSchema returns the values of the one `enum:` inside the named schema of the canon.
//
// ⛔ THE SEARCH IS BOUNDED BY THE SCHEMA'S OWN BODY, and the bound is the whole of this function. The
// first edition was one regexp — `\n Blocked:\n.*?\n +enum: \[…\]` — whose `.*?` was stopped by
// nothing: with the enum deleted from the canon it walked on and read the NEXT schema's, so a missing
// closed vocabulary came back as `[exhausted low ok]` from `Usage.state` seventy lines below. Red, and
// red for the wrong reason; move those two words under the following schema instead and the gate goes
// GREEN over a canon that no longer ratifies them at all. A gate that can read a neighbour's answer is
// not a second source, it is a coin.
//
// The body is every line indented deeper than the schema's own name, which is what YAML means by
// nesting and what the canon does everywhere. Pinned by TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody,
// whose fixtures are exactly the two shapes above.
func enumOfSchema(canon, schema string) (map[string]bool, error) {
head := "\n " + schema + ":\n"
i := strings.Index(canon, head)
if i < 0 {
return nil, fmt.Errorf("no schema %q in the document", schema)
}
body := canon[i+len(head):]
// …up to the next line that is indented no deeper than the schema's name: the next schema, or the
// end of the section.
if end := regexp.MustCompile(`(?m)^ {0,4}\S`).FindStringIndex(body); end != nil {
body = body[:end[0]]
}
m := regexp.MustCompile(`(?m)^ +enum: \[([^\]]*)\]`).FindStringSubmatch(body)
if m == nil {
return nil, fmt.Errorf("schema %q carries no enum", schema)
}
out := map[string]bool{}
for _, v := range strings.Split(m[1], ",") {
if v = strings.TrimSpace(v); v != "" {
out[v] = true
}
}
return out, nil
}
// The reader stops at the schema's own body — asserted on documents built for the question, because
// the live canon cannot show the failure this exists to prevent.
//
// Both fixtures are the adversarial pass's own posting: delete the enum and see whether a neighbour's
// is read instead. The first says the reader must REFUSE; the second says it must refuse even when the
// missing words are sitting a few lines below under another name, which is the shape that came back
// GREEN from the first edition.
func TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody(t *testing.T) {
const withIt = `
Blocked:
type: object
properties:
code:
type: string
enum: [credit_held, run_in_flight]
Usage:
properties:
state:
enum: [exhausted, low, ok]
`
got, err := enumOfSchema(withIt, "Blocked")
if err != nil || !got["credit_held"] || !got["run_in_flight"] || len(got) != 2 {
t.Fatalf("the reader did not read the schema's own enum: %v (%v)", keys(got), err)
}
// The enum is GONE from Blocked and a neighbour still has one.
const withoutIt = `
Blocked:
type: object
properties:
code:
type: string
Usage:
properties:
state:
enum: [exhausted, low, ok]
`
if got, err := enumOfSchema(withoutIt, "Blocked"); err == nil {
t.Errorf("a schema with no enum answered %v: the reader walked into the next schema", keys(got))
}
// And the same, with the missing words present further down under another name — the shape that
// makes a lenient reader answer RIGHT for the wrong document.
const movedAway = `
Blocked:
type: object
properties:
code:
type: string
Somewhere:
properties:
code:
enum: [credit_held, run_in_flight]
`
if got, err := enumOfSchema(movedAway, "Blocked"); err == nil {
t.Errorf("the words were read from the wrong schema: %v", keys(got))
}
}