A local name such as records is no provision: on Requires it would send the placement looking for a provider nothing offers (the lead's ruling on issue 1966 B1). BusState alone holds the typed entries.
132 lines
5.4 KiB
Go
132 lines
5.4 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
|
|
}{
|
|
// 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},
|
|
} {
|
|
got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`))
|
|
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)
|
|
}
|
|
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
|
|
}
|