Files
mesh-sdk/go/catalogue/optional_requirements_test.go
T
jschoubben c6a6bec3ab G M-6: carry a template events entry's events, and name objects-max-mib (issue 1966)
A part's finding stream asked by the template events lists its three finding
events in its field events, which the catalog graph entry carries; read
leniently it was set aside. A state's objects store cap is the option
objects-max-mib.

Builds: design 47 G §529, §513, §547, §607

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 15:46:33 +01:00

167 lines
7.7 KiB
Go

package catalogue
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// A requirement a module can run without (novox/hq ADR 0330, issue 166). The shape of the issue:
// grafana runs without a data source and requires `influxdb-api` softly; a mesh without influxdb
// could not assign grafana at all, and one with influxdb in its catalogue would have had it installed
// for grafana. And `requires` in its typed long form, a module's bus state (novox/hq ADR 0595 §1).
func TestARequirementsLongForm(t *testing.T) {
m := mustParse(t, `{"module":"grafana","version":"1",
"requires":["route",{"name":"influxdb-api","optional":true},{"name":"oidc-client"}]}`)
if strings.Join(m.Requires, " ") != "route influxdb-api oidc-client" {
t.Fatalf("the requirements read as %v", m.Requires)
}
if m.IsOptional("route") || m.IsOptional("oidc-client") {
t.Error("a requirement without `optional` read as optional; hard is the default (ADR 0330 §1)")
}
if !m.IsOptional("influxdb-api") {
t.Error("a requirement marked optional read as hard")
}
// And written back as it was read, so a manifest through the store keeps its form.
again := roundTrip(t, m)
if !again.IsOptional("influxdb-api") || again.IsOptional("route") || len(again.Requires) != 3 {
t.Fatalf("written and read back, it says %v optional %v", again.Requires, again.OptionalRequires)
}
// The long form {name, optional} refuses what it does not know.
for _, raw := range []string{
`{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`,
`{"module":"x","version":"1","requires":[{"optional":true}]}`,
} {
if _, err := decodeForTest([]byte(raw)); err == nil {
t.Errorf("accepted %s", raw)
}
}
records := BusStateEntry{Name: "records", Storage: "ledger", Template: "ledger",
Options: map[string]json.RawMessage{"max-mib": json.RawMessage(`1024`)}}
// A ledger type with each of the four effects.
listing := LedgerType{Name: "listing",
Schema: map[string]LedgerField{"price": {Type: "number"}, "seen": {Type: "list"}, "notes": {Type: "map"},
"text": {Type: "text"}, "digest": {Type: "text", DigestOf: "text"}},
Operations: []LedgerOperation{
{Name: "priced", Effect: EffectSetFields, Fields: []string{"price"}, Sequence: true, Author: true},
{Name: "noted", Effect: EffectSetEntry, Fields: []string{"text", "digest"}, Into: "notes", Key: "title"},
{Name: "seen", Effect: EffectAppend, Fields: []string{"at"}, Into: "seen"},
{Name: "unnoted", Effect: EffectRemove, Into: "notes", Key: "title"},
},
Indexes: []LedgerIndex{{Name: "cheapest", On: []string{"price"}}}}
typeJSON, err := json.Marshal(listing)
if err != nil {
t.Fatal(err)
}
ledger := records
ledger.Types = []LedgerType{listing}
for _, c := range []struct {
name string
requires string
names string
busState []BusStateEntry
aside []string
refused string
}{
// 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions,
// and BusState holds the entry whole.
{"a plain name and a bus-state entry", `["postgres-database",
{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`,
"postgres-database", []BusStateEntry{records}, nil, ""},
// 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes.
{"a bus-state entry with a key it does not know", `[
{"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`,
"", []BusStateEntry{records}, []string{"requires[0].colour"}, ""},
// 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged.
{"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024},
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""},
// Issue 1966: a key-value entry of `requires` is kept state beside the old section's, by its local name.
{"a key-value entry beside an old state", `[{"name":"holds","storage":"key-value","template":"state"}], "state": ["seen"]`,
"", []BusStateEntry{{Name: "holds", Storage: StorageKeyValue, Template: TemplateState}}, nil, ""},
// G §529: a template events entry's field `events` is carried, not set aside; G §513: `objects-max-mib` is a
// state's named option.
{"a finding stream and a state's objects cap", `[{"name":"finding-events","storage":"stream","template":"events",
"options":{"max-age-days":7,"max-mib":16},"events":["finding-written","finding-cleared","findings-stated"]},
{"name":"findings","storage":"key-value","template":"state","options":{"` + OptionObjectsCapMiB + `":512}}]`,
"", []BusStateEntry{{Name: "finding-events", Storage: StorageStream, Template: TemplateEvents,
Options: map[string]json.RawMessage{"max-age-days": json.RawMessage(`7`), "max-mib": json.RawMessage(`16`)},
Events: []string{"finding-written", "finding-cleared", "findings-stated"}},
{Name: "findings", Storage: StorageKeyValue, Template: TemplateState,
Options: map[string]json.RawMessage{"objects-max-mib": json.RawMessage(`512`)}}}, nil, ""},
// A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word.
{"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"},
{"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`,
"", nil, nil, "the field requires[0].name is given twice"},
} {
got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`))
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Errorf("%s: read as %+v, %v; want refused: %s", c.name, got, err, c.refused)
}
continue
}
if err != nil {
t.Errorf("%s: refused: %v", c.name, err)
continue
}
if strings.Join(got.Requires, " ") != c.names {
t.Errorf("%s: requires read as %v, want %s", c.name, got.Requires, c.names)
}
if !reflect.DeepEqual(got.BusState, c.busState) {
t.Errorf("%s: bus state read as %+v, want %+v", c.name, got.BusState, c.busState)
}
// Issue 1966: a ledger is no kept state; KeptStates names the old section's and the key-value entries' alone.
var keeps []string
switch c.name {
case "a key-value entry beside an old state":
keeps = []string{"holds", "seen"}
case "a finding stream and a state's objects cap":
keeps = []string{"findings"}
}
if kept := KeptStates(got); !reflect.DeepEqual(kept, keeps) {
t.Errorf("%s: kept states %v, want %v", c.name, kept, keeps)
}
for _, path := range c.aside {
if !strings.Contains(got.SetAside(), path) {
t.Errorf("%s: %s is not named among what was set aside: %q", c.name, path, got.SetAside())
}
}
if len(c.aside) == 0 && got.SetAside() != "" {
t.Errorf("%s: set aside %q", c.name, got.SetAside())
}
// Stored and read back, the entries stay whole and `requires` stays plain names.
back := roundTrip(t, got)
if !reflect.DeepEqual(back.BusState, c.busState) || strings.Join(back.Requires, " ") != c.names {
t.Errorf("%s: read back as %v %+v", c.name, back.Requires, back.BusState)
}
}
}
// roundTrip is m written as the store keeps it and read again; the stored form keeps `requires` plain names.
func roundTrip(t *testing.T, m Manifest) Manifest {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
var stored struct {
Requires []any `json:"requires"`
}
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatal(err)
}
for _, r := range stored.Requires {
if _, plain := r.(string); !plain {
t.Errorf("a requirement was stored in the long form: %s", raw)
}
}
var again Manifest
if err := json.Unmarshal(raw, &again); err != nil {
t.Fatalf("%v: %s", err, raw)
}
return again
}