Files
mesh-sdk/go/seatdef/seatdef_test.go
T
jschoubben 078d3f53bf RR-1, RR-2: the seat-definition reader reads callers as text or list, class, keep as an object, step pipeline and on kind:image
Text callers stay read in the release (G's own reader refuses them); both old and new keep forms are read; check
stays beside pipeline.

Builds: design 47 G §340, §409, §336, §338, §391, §396, §397, §203, §266, §324

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:35:22 +01:00

264 lines
18 KiB
Go

package seatdef
import (
"errors"
"strings"
"testing"
)
// TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses;
// each missing required field is refused naming it; one over 256 KiB is refused), with the parts and fixtures beside.
func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) {
const listGrain = `{"format":1,"seat":"node-hostname","scope":"node","grain":"seat-list","domain":"Core","decision":"ADR 1",
"verbs":[{"name":"add","input":{"type":"object"}}]}`
const whole = `{"format":1,"seat":"mesh-broker","scope":"mesh","grain":"whole","domain":"Core","decision":"ADR 1",
"verbs":[{"name":"list","input":{"type":"object"},"answer":{"type":"object"}}],
"events":[{"name":"created","body":{"type":"object"}}],"family":{"name":"broker.events","max-age-days":7}}`
cases := []struct {
name, raw string
refused string // a text the refusal names, empty when it parses
}{
{"the seat list's grain parses", listGrain, ""},
{"a whole definition parses", whole, ""},
{"no format", `{"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "format: missing"},
{"a later format", `{"format":2,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "format 2"},
{"no seat", `{"format":1,"scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "seat: missing"},
{"no scope", `{"format":1,"seat":"a-b","grain":"seat-list","domain":"Core","decision":"x"}`, "scope: missing"},
{"another scope", `{"format":1,"seat":"a-b","scope":"room","grain":"seat-list","domain":"Core","decision":"x"}`, "scope"},
{"no grain", `{"format":1,"seat":"a-b","scope":"mesh","domain":"Core","decision":"x"}`, "grain: missing"},
{"no decision", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list"}`, "decision: missing"},
{"no domain", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "domain: missing"},
{"a domain the glossary lacks", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Weather","decision":"x"}`, "domain"},
{"the Placement domain is gone (ADR 0641 section 5)", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Placement","decision":"x"}`, "domain"},
{"an unknown field", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x","sevres":[]}`,
"sevres: a field the format does not know"},
{"a whole verb with no answer", strings.Replace(whole, `,"answer":{"type":"object"}`, "", 1),
"verbs[0].answer: missing"},
{"a whole verb with no input", strings.Replace(whole, `"input":{"type":"object"},`, "", 1),
"verbs[0].input: missing"},
{"a whole event with no body", strings.Replace(whole, `"name":"created","body":{"type":"object"}`,
`"name":"created"`, 1), "events[0].body: missing"},
{"whole events kept nowhere", strings.Replace(whole, `,"family":{"name":"broker.events","max-age-days":7}`,
"", 1), "family: missing"},
{"a family kept longer than the shared events stream", strings.Replace(whole, `"max-age-days":7`,
`"max-age-days":30`, 1), "family.max-age-days: missing, or not 7"},
{"a family with a cap of its own", strings.Replace(whole, `"max-age-days":7`, `"max-age-days":7,"max-mib":256`, 1),
"max-mib: a field the format does not know"},
{"a holder's event on its own stream", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+
`"subject":"mesh.stream.<holder>.event.verdict","family":"checker.asks"}],"family":`, 1), ""},
{"a holder's event on its own stream with no family", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+
`"subject":"mesh.stream.<holder>.event.verdict"}],"family":`, 1), "no family names that stream"},
{"a holder's event kept in its own stream under its module's subjects", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+
`"subject":"mesh.mod.<holder>.event.verdict","family":"checker.asks"}],"family":`, 1), "a family names the holder's own stream"},
{"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+
`"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod.<holder>.event.verdict"},
{"an event with dotted words", strings.Replace(whole, `"name":"created"`, `"name":"pull.updated"`, 1), ""},
{"an event whose subject carries parts", strings.Replace(whole, `"name":"created"`,
`"name":"milestone.asked","subject":"milestone.<milestone>.asked"`, 1), ""},
{"a subject whose words are not the name", strings.Replace(whole, `"name":"created"`,
`"name":"owed","subject":"owing.<repository>"`, 1), `its words are "owing", not the event's name "owed"`},
{"a subject with no part", strings.Replace(whole, `"name":"created"`, `"name":"owed","subject":"owed"`, 1),
"events[0].subject: no part"},
{"a subject's part twice", strings.Replace(whole, `"name":"created"`,
`"name":"running","subject":"running.<node>.<node>"`, 1), "part <node> twice"},
{"a subject on an event named by its caller", strings.Replace(whole, `"name":"created"`,
`"name":"owed","by-caller":true,"subject":"owed.<number>"`, 1), "carries no subject of its own"},
{"an event kept last per subject", strings.Replace(whole, `"name":"created"`,
`"name":"running","subject":"running.<node>.<module>","keep":"last-per-subject"`, 1), ""},
{"an event kept otherwise", strings.Replace(whole, `"name":"created"`,
`"name":"running","subject":"running.<node>","keep":"forever"`, 1), `events[0].keep: "forever" is not last-per-subject or stream`},
{"an event kept in a stream of its own, bounded", strings.Replace(whole, `"name":"created"`,
`"name":"owed","subject":"owed.<repository>.<number>","keep":"stream","max-age-days":7,"max-mib":64`, 1), ""},
{"an event kept in a stream of its own, unbounded", strings.Replace(whole, `"name":"created"`,
`"name":"owed","subject":"owed.<repository>.<number>","keep":"stream","max-age-days":7`, 1), "gives its max-age-days and max-mib"},
{"a bound on an event kept by its family", strings.Replace(whole, `"name":"created"`,
`"name":"owed","max-mib":64`, 1), "events[0].max-age-days: only an event kept as a stream"},
{"an event of one subject kept last per subject", strings.Replace(whole, `"name":"created"`,
`"name":"running","keep":"last-per-subject"`, 1), "give it a subject with parts"},
{"an accept with dotted words", strings.Replace(whole, `"events":`,
`"accepts":[{"name":"run.now","body":{}}],"events":`, 1), "accepts[0].name"},
{"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`},
{"states at the seat list's grain", strings.Replace(listGrain, `"domain":"Core","decision":"ADR 1",`,
`"domain":"Core","decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"},
{"the old records field is gone", strings.Replace(whole, `"domain":"Core","decision":"ADR 1",`,
`"domain":"Core","decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"},
{"a state with a key form and a value", withState(whole, `{"name":"asks","template":"state","writer":"caller",
"history":3,"key-forms":[{"key":"<asker>.<id>","value":{"type":"object"}}]}`), ""},
{"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7,
"key-forms":[{"key":"changed.<node>","value":{"type":"object"}}]}`), ""},
{"a state of an unknown template", withState(whole, `{"name":"asks","template":"bucket",
"key-forms":[{"key":"<id>","value":{"type":"object"}}]}`), `states[0].template: "bucket" is none of state, events`},
{"a key form with no value", withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"<id>"}]}`),
`states[0].key-forms[0].value: missing: key form "<id>"`},
{"a state with no key form", withState(whole, `{"name":"asks","template":"state"}`), "states[0].key-forms: missing"},
{"a key part that is no name", withState(whole, `{"name":"asks","template":"state",
"key-forms":[{"key":"<Asker>.<id>","value":{}}]}`), "part <Asker> is not a lower-case name"},
{"a state written by neither", withState(whole, `{"name":"asks","template":"state","writer":"anyone",
"key-forms":[{"key":"<id>","value":{}}]}`), "states[0].writer"},
{"history of a stream", withState(whole, `{"name":"asks","template":"log","history":2,
"key-forms":[{"key":"<id>","value":{}}]}`), "states[0].history"},
{"a state twice", withState(withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"<id>","value":{}}]}`),
`{"name":"asks","template":"log","key-forms":[{"key":"<id>","value":{}}]}`), `"asks" twice`},
// W1.1: a seat's acting rules (design 47 *What ADR 0656 leaves to this design*), at either grain.
{"acting rules", strings.Replace(whole, `"family":`, `"acts":[{"step":"take-in"},{"step":"validate","on":"every"},`+
`{"step":"build","on":"go","bound":"60m"}],"family":`, 1), ""},
{"an acting rule at the seat list's grain", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"compose","bound":"30m"}],"verbs":`, 1), ""},
{"an acting rule with no step", strings.Replace(listGrain, `"verbs":`, `"acts":[{"on":"go"}],"verbs":`, 1), "acts[0].step: missing"},
{"an acting rule of an unknown step", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"deploy"}],"verbs":`, 1),
`acts[0].step: "deploy" is not a step (take-in, validate, check, build, compose, grant, place, pipeline)`},
{"an acting rule on no language", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","on":"Go Lang"}],"verbs":`, 1),
"acts[0].on"},
{"an acting rule's bound that is no duration", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","bound":"an hour"}],"verbs":`, 1),
`acts[0].bound: "an hour" is not a duration`},
{"an acting rule's field the format does not know", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","when":"now"}],"verbs":`, 1),
"when: a field the format does not know"},
{"one step on one language twice", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"grant"},{"step":"grant","on":"every"}],"verbs":`, 1),
"acts[1]: the step grant on every twice"},
// The reader release (design 47 G §340, §409): the forms G writes beside the ones on main.
{"callers as text", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":"a person",`, 1), ""},
{"callers as a list", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":["operator","seat:mesh-grants","invokers"],"class":"read",`, 1), ""},
{"callers as neither", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":7,`, 1), "callers is a text or a list"},
{"a list with an empty entry", strings.Replace(whole, `"name":"list",`, `"name":"list","callers":["operator",""],`, 1), "verbs[0].callers[1]: is empty"},
{"a class that is none", strings.Replace(whole, `"name":"list",`, `"name":"list","class":"free",`, 1), `verbs[0].class: "free" is not a class (read, act, warrant)`},
{"an accepted kind's callers", strings.Replace(whole, `"events":`, `"accepts":[{"name":"step","body":{},"callers":["seat:mesh-pipeline-runner"]}],"events":`, 1), ""},
{"an event's callers", strings.Replace(whole, `"name":"created"`, `"name":"created","callers":["operator"]`, 1), "only an accepted kind carries callers"},
{"keep as an object", strings.Replace(whole, `"name":"created"`,
`"name":"owed","keep":{"max-age-days":7,"max-mib":64}`, 1), ""},
{"keep as an object with no age limit", strings.Replace(whole, `"name":"created"`,
`"name":"owed","keep":{"max-age-days":0,"last-per-subject":true},"subject":"owed.<repository>"`, 1), ""},
{"keep as an object, last per subject, one subject", strings.Replace(whole, `"name":"created"`,
`"name":"owed","keep":{"last-per-subject":true}`, 1), "give it a subject with parts"},
{"keep as an object with a field it does not know", strings.Replace(whole, `"name":"created"`,
`"name":"owed","keep":{"days":7}`, 1), "keep is a string or an object"},
{"keep as an object with a negative age", strings.Replace(whole, `"name":"created"`,
`"name":"owed","keep":{"max-age-days":-1}`, 1), "events[0].keep.max-age-days: is negative"},
{"the step pipeline and an image rule", strings.Replace(listGrain, `"verbs":`,
`"acts":[{"step":"pipeline"},{"step":"build","on":"kind:image"},{"step":"check"}],"verbs":`, 1), ""},
{"an acting rule on another kind", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","on":"kind:Image"}],"verbs":`, 1), "acts[0].on"},
{"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`,
ErrTooLarge.Error()},
}
for _, c := range cases {
_, err := Parse([]byte(c.raw))
switch {
case c.refused == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.refused != "" && err == nil:
t.Errorf("%s: parsed, want refused naming %q", c.name, c.refused)
case c.refused != "" && !strings.Contains(err.Error(), c.refused):
t.Errorf("%s: refused with %q, want it to name %q", c.name, err, c.refused)
}
}
if _, err := Parse([]byte(listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) +
`"}`)); !errors.Is(err, ErrTooLarge) {
t.Errorf("over 256 KiB is ErrTooLarge, got %v", err)
}
// A part is taken in whole, its verbs before the definition's own; a part not given, or a type defined twice,
// is refused.
part, err := ParsePart([]byte(`{"format":1,"part":"registry","verbs":[{"name":"publish","input":{"type":"object"},
"answer":{"type":"object"}}],"events":[{"name":"published","body":{"type":"object"}}],
"defs":{"Ref":{"type":"string"}}}`))
if err != nil {
t.Fatal(err)
}
d, err := Parse([]byte(strings.Replace(whole, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","includes":["registry"],`, 1)))
if err != nil {
t.Fatal(err)
}
got, err := Resolve(d, map[string]Part{"registry": part})
if err != nil {
t.Fatal(err)
}
if v := VerbNames(got.Verbs); strings.Join(v, ",") != "publish,list" {
t.Errorf("resolved verbs %v, want the part's first", v)
}
if m := MessageNames(got.Events); strings.Join(m, ",") != "published,created" || got.Defs["Ref"] == nil {
t.Errorf("resolved events %v, defs %v", m, got.Defs)
}
if _, err := Resolve(d, nil); err == nil || !strings.Contains(err.Error(), `part "registry" is not given`) {
t.Errorf("a part not given: %v", err)
}
d.Defs = map[string]map[string]any{"Ref": {"type": "string"}}
if _, err := Resolve(d, map[string]Part{"registry": part}); err == nil || !strings.Contains(err.Error(), "defs.Ref") {
t.Errorf("a type defined twice: %v", err)
}
// Fixtures name what they are an instance of.
if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created",
"subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil {
t.Errorf("fixtures: %v", err)
}
if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks/<asker>.<id>","given":{}}]`)); err != nil {
t.Errorf("a state's fixture: %v", err)
}
if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks","given":{}}]`)); err == nil {
t.Error("a fixture of a state with no key form parsed")
}
if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list","given":{}}]`)); err == nil ||
!strings.Contains(err.Error(), "fixtures[0].of") {
t.Errorf("a fixture of nothing known: %v", err)
}
if _, err := ParseIndex([]byte(`{"format":1,"seats":["a-b","a-b"]}`)); err == nil {
t.Error("an index naming a seat twice parsed")
}
}
// withState adds one state to a whole definition's JSON.
func withState(def, state string) string {
if strings.Contains(def, `"states":[`) {
return strings.Replace(def, `"states":[`, `"states":[`+state+`,`, 1)
}
return strings.Replace(def, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","states":[`+state+`],`, 1)
}
// TestAnEventsSubjectNamesItsParts holds the three readings of an event's subject: its pattern, its parts in order and
// the local name a manifest's emits and consumes give it.
func TestAnEventsSubjectNamesItsParts(t *testing.T) {
for _, c := range []struct {
m Message
pattern, parts, local string
}{
{Message{Name: "created"}, "created", "", "created"},
{Message{Name: "pull.updated"}, "pull.updated", "", "pull.updated"},
{Message{Name: "running", Subject: "running.<node>.<module>"}, "running.<node>.<module>", "node,module", "running.*.*"},
{Message{Name: "milestone.asked", Subject: "milestone.<milestone>.asked"}, "milestone.<milestone>.asked", "milestone", "milestone.*.asked"},
} {
if got := c.m.SubjectPattern(); got != c.pattern {
t.Errorf("%s: pattern %q, want %q", c.m.Name, got, c.pattern)
}
if got := strings.Join(c.m.Parts(), ","); got != c.parts {
t.Errorf("%s: parts %q, want %q", c.m.Name, got, c.parts)
}
if got := c.m.Local(); got != c.local {
t.Errorf("%s: local %q, want %q", c.m.Name, got, c.local)
}
}
}
// W1.1: what an acting rule reads as where it says less (design 47 *An acting part's new version acts first*).
func TestAnActingRuleReadsItsStepsDefaults(t *testing.T) {
for _, c := range []struct {
rule ActingRule
language string
acts bool
wait string
}{
{ActingRule{Step: StepBuild, On: "go"}, "go", true, "1h0m0s"},
{ActingRule{Step: StepBuild, On: "go"}, "typescript", false, "1h0m0s"},
{ActingRule{Step: StepCompose}, "anything", true, "30m0s"},
{ActingRule{Step: StepCheck, On: OnEvery}, "", true, "20m0s"},
{ActingRule{Step: StepGrant, Bound: "5m"}, "go", true, "5m0s"},
{ActingRule{Step: StepPlace}, "go", true, "10m0s"},
{ActingRule{Step: StepTakeIn}, "go", true, "0s"},
{ActingRule{Step: StepPipeline}, "go", true, "30m0s"},
} {
if got := c.rule.ActsOn(c.language); got != c.acts {
t.Errorf("%+v acts on %q: %v, want %v", c.rule, c.language, got, c.acts)
}
if got := c.rule.Wait().String(); got != c.wait {
t.Errorf("%+v waits %s, want %s", c.rule, got, c.wait)
}
}
}