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 }