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
167 lines
7.7 KiB
Go
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
|
|
}
|