Files
mesh-catalog/modules/claude-licence-manager/cmd/tools/tools_test.go
T
jschoubben ee6d7c284d
mesh/delivery merged: source.main moved to novox/mesh-catalog@23a280a664be
mesh/check being carried to ee6d7c284da9 on 23a280a664be | from f1c04801e477
The carried-switch test names no real node or address (review round 1)
2026-11-12 15:40:10 +01:00

361 lines
13 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"testing"
"time"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"claude-licence-manager/internal/licences"
)
// runtime is a fixture of the runtime's bus-state and log calls (design 39 parts §3): it answers every read from what
// a test seeds, and fails the test on any write or append the tools bundle makes.
type runtime struct {
t *testing.T
states map[string]map[string]json.RawMessage
logs map[string][]stdio.LogEntry
}
func newRuntime(t *testing.T) *runtime {
rt := &runtime{t: t, states: map[string]map[string]json.RawMessage{}, logs: map[string][]stdio.LogEntry{}}
for _, s := range licences.States {
rt.states[s] = map[string]json.RawMessage{}
}
for _, l := range licences.Logs {
rt.logs[l] = nil
}
return rt
}
// seedState and seedLog keep what the manager wrote before the tools were asked.
func (rt *runtime) seedState(state, key string, v any) {
raw, _ := json.Marshal(v)
rt.states[state][key] = raw
}
func (rt *runtime) seedLog(log, key string, v any) {
raw, _ := json.Marshal(v)
rt.logs[log] = append(rt.logs[log], stdio.LogEntry{Sequence: uint64(len(rt.logs[log]) + 1), Key: key, Value: raw})
}
func (rt *runtime) reader() *Reader {
r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{}}
for s := range rt.states {
r.States[s] = keptState{rt, s}
}
for l := range rt.logs {
r.Logs[l] = keptLog{rt, l}
}
return r
}
type keptState struct {
rt *runtime
name string
}
func (s keptState) wrote(how, key string) error {
s.rt.t.Errorf("the tools bundle %s %s.%s: it writes nothing", how, s.name, key)
return errors.New("refused: the tools bundle writes nothing")
}
func (s keptState) Get(key string) (*stdio.StateEntry, error) {
v, ok := s.rt.states[s.name][key]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: v, Revision: 1}, nil
}
func (s keptState) Put(key string, _ any) (uint64, error) { return 0, s.wrote("put", key) }
func (s keptState) PutTTL(key string, _ any, _ time.Duration) (uint64, error) {
return 0, s.wrote("put", key)
}
func (s keptState) Create(key string, _ any) (uint64, error) { return 0, s.wrote("created", key) }
func (s keptState) CreateTTL(key string, _ any, _ time.Duration) (uint64, error) {
return 0, s.wrote("created", key)
}
func (s keptState) Delete(key string) error { return s.wrote("deleted", key) }
func (s keptState) Keys() ([]string, error) {
keys := []string{}
for k := range s.rt.states[s.name] {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
func (s keptState) History(key string) ([]stdio.StateRevision, error) { return nil, nil }
type keptLog struct {
rt *runtime
name string
}
func (l keptLog) Append(key string, _ any) (uint64, error) {
l.rt.t.Errorf("the tools bundle appended to %s.%s: it writes nothing", l.name, key)
return 0, errors.New("refused: the tools bundle writes nothing")
}
func (l keptLog) Read(from uint64, keys string, _ int) (*stdio.LogPage, error) {
page := &stdio.LogPage{Last: uint64(len(l.rt.logs[l.name]))}
for _, e := range l.rt.logs[l.name] {
if e.Sequence >= from && (keys == "" || e.Key == keys) {
page.Entries = append(page.Entries, e)
}
}
return page, nil
}
func (l keptLog) Last(key string) (*stdio.LogEntry, error) {
var last *stdio.LogEntry
for _, e := range l.rt.logs[l.name] {
if e.Key == key {
last = &e
}
}
return last, nil
}
func (l keptLog) Info() (*stdio.LogInfo, error) {
n := uint64(len(l.rt.logs[l.name]))
return &stdio.LogInfo{Entries: n, Last: n, First: min(n, 1), MaxBytes: 64 << 20}, nil
}
func (l keptLog) Newest(string) ([]stdio.LogEntry, error) {
by := map[string]stdio.LogEntry{}
for _, e := range l.rt.logs[l.name] {
by[e.Key] = e
}
out := []stdio.LogEntry{}
for _, e := range by {
out = append(out, e)
}
return out, nil
}
// run is one tool's answer as JSON, decoded into a generic form.
func run(t *testing.T, r *Reader, name string, args map[string]any) any {
t.Helper()
for _, tool := range r.Tools() {
if tool.Name != name {
continue
}
got, err := tool.Run(args)
if err != nil {
t.Fatalf("%s %v answered %v", name, args, err)
}
raw, _ := json.Marshal(got)
var v any
_ = json.Unmarshal(raw, &v)
return v
}
t.Fatalf("the tools bundle registers no %s", name)
return nil
}
// design 39 parts §3, §4: the tools bundle registers exactly the four reading tools; its code emits nothing,
// subscribes to no event, asks no heartbeat, calls neither the vendor nor the vault, and asks nothing but mesh-config.
func TestTheToolsBundleRegistersTheFourReadingToolsAndActsOnNothing(t *testing.T) {
var names []string
for _, tool := range newRuntime(t).reader().Tools() {
names = append(names, tool.Name)
}
sort.Strings(names)
if want := []string{"licences_conditions", "licences_kept", "licences_settings", "licences_switches"}; !slices.Equal(names, want) {
t.Fatalf("the tools bundle registers %v, not %v", names, want)
}
files, _ := filepath.Glob("*.go")
acts := regexp.MustCompile(`stdio\.(Emit|Subscribe|SeatPublish|Publish|Heartbeat|Watch)\b|\.(Put|PutTTL|Create|CreateTTL|Delete|Append)\(|os\.(WriteFile|Create|OpenFile)|http\.`)
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range acts.FindAllString(string(src), -1) {
t.Errorf("%s calls %s: the tools bundle acts on nothing", f, m)
}
for _, m := range asked.FindAllStringSubmatch(string(src), -1) {
if m[1] != "seat:mesh-config.config" {
t.Errorf("%s asks %s: the tools bundle asks only mesh-config", f, m[1])
}
}
}
}
// design 39 bus-and-switch §37/AC1, design 39 parts §3: a switch by usage and one by a person are answered, newest
// first, with their causes, a switch by usage with the reading that moved them; licence and consumer narrow the
// answer; nothing is written.
func TestSwitchesAnswersBothCauses(t *testing.T) {
rt := newRuntime(t)
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:00:00Z", "from": "a", "to": "b",
"consumers": []string{"ace", "g14"}, "by": "usage", "why": "a at 99 %", "window": "weekly", "pct": 99,
"resetsAt": "2026-11-14T09:00:00Z", "room": 60})
rt.seedLog(licences.EntryAudit, "bound", map[string]any{"at": "2026-11-12T10:00:30Z", "consumer": "ace"})
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:01:00Z", "from": "b", "to": "c",
"consumers": []string{"ace"}, "by": "a person", "why": "the switch verb"})
r := rt.reader()
got := run(t, r, "licences_switches", nil).([]any)
if len(got) != 2 {
t.Fatalf("switches %v", got)
}
person, usage := got[0].(map[string]any), got[1].(map[string]any)
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
usage["by"] != "usage" || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
t.Fatalf("switches %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"licence": "c"}).([]any); len(got) != 1 {
t.Fatalf("of c %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"consumer": "g14"}).([]any); len(got) != 1 ||
got[0].(map[string]any)["by"] != "usage" {
t.Fatalf("of g14 %v", got)
}
if got := run(t, r, "licences_switches", map[string]any{"since": "2026-11-12T10:00:30Z"}).([]any); len(got) != 1 {
t.Fatalf("since %v", got)
}
}
// design 39 bus-and-switch §37, §12: a switch carried over from the old table keeps the old field names (consumer,
// licence) and is answered with `to` and `consumers`, and found by them.
func TestSwitchesAnswersCarriedEntries(t *testing.T) {
rt := newRuntime(t)
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "n1",
"licence": "d@example.org", "from": "c@example.org", "by": "a@example.org"})
r := rt.reader()
for _, args := range []map[string]any{nil, {"consumer": "n1"}, {"licence": "d@example.org"}} {
got := run(t, r, "licences_switches", args).([]any)
if len(got) != 1 {
t.Fatalf("%v: %v", args, got)
}
e := got[0].(map[string]any)
if e["to"] != "d@example.org" || e["from"] != "c@example.org" || e["by"] != "a@example.org" ||
len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "n1" {
t.Fatalf("%v: %v", args, e)
}
}
}
// design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the
// rest with the default, each with its value and default, and an invalid one saying why, read from the settings file;
// mesh-config not answering is an error, never the default.
func TestSettingsAnswersTheLevel(t *testing.T) {
r := newRuntime(t).reader()
r.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(r.SettingsFile, []byte(`{"switch_at_pct": 80, "warn_at_pct": 85, "usage_minutes": 900}`), 0o600); err != nil {
t.Fatal(err)
}
r.ConfigLevels = func(context.Context) (map[string]string, error) {
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
}
by := map[string]map[string]any{}
for _, s := range run(t, r, "licences_settings", nil).([]any) {
f := s.(map[string]any)
by[f["setting"].(string)] = f
}
str := func(v any) string { s, _ := v.(string); return s }
if len(by) != len(licences.Defaults.Values()) || by["switch_at_pct"]["level"] != "node" ||
by["switch_at_pct"]["value"] != 80.0 || by["switch_at_pct"]["default"] != 99.0 ||
by["usage_minutes"]["level"] != "default" || by["usage_minutes"]["value"] != 5.0 ||
!strings.Contains(str(by["usage_minutes"]["invalid"]), "outside its bound") ||
!strings.Contains(str(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
!strings.Contains(str(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
t.Fatalf("settings %v", by)
}
r.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
for _, tool := range r.Tools() {
if tool.Name == "licences_settings" {
if _, err := tool.Run(nil); err == nil {
t.Fatal("mesh-config not answering is answered as the default")
}
}
}
}
// design 39 bus-and-switch §41/AC1, design 39 parts §3, §10: every bus-state entry the manifest asks for is read by
// licences_kept, with its keys or subjects, one key's value and its fill, the carry-over's record among them; an
// entry not kept is refused; nothing is written.
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Requires []struct {
Name string `json:"name"`
Template string `json:"template"`
} `json:"requires"`
Logs []struct {
Name string `json:"name"`
} `json:"logs"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
rt := newRuntime(t)
rt.seedState(licences.EntryLicences, "a_at_example.org", licences.Licence{Name: "a@example.org", Kind: "subscription"})
rt.seedState(licences.EntryCarried, "postgres", map[string]any{"at": "2026-11-12T09:44:59Z"})
rt.seedLog(licences.EntryUsage, "a_at_example.org", licences.UsageEntry{At: "2026-11-12T10:00:00Z"})
r := rt.reader()
all := run(t, r, "licences_kept", nil).(map[string]any)
kinds := map[string]string{}
for _, e := range m.Requires {
kinds[e.Name] = e.Template
}
for _, l := range m.Logs {
kinds[l.Name] = "log"
}
for name, kind := range kinds {
got, ok := all[name].(map[string]any)
if !ok || got["fill"] == nil || got["kind"] != kind {
t.Errorf("licences_kept does not read %s as a %s: %v", name, kind, all[name])
}
}
if len(kinds) != 8 || len(all) != len(kinds) {
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", len(kinds), len(all))
}
one := run(t, r, "licences_kept", map[string]any{"entry": "carried", "key": "postgres"}).(map[string]any)
if v, _ := one["carried"].(map[string]any)["value"].(map[string]any); v["at"] != "2026-11-12T09:44:59Z" {
t.Fatalf("the carry-over's record %v", one)
}
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects["a_at_example.org"] == nil {
t.Fatalf("usage subjects %v", subjects)
}
if _, err := r.Kept("tables", ""); err == nil {
t.Fatal("an entry not kept is answered")
}
}
// design 39 bus-and-switch §43/AC1, design 39 parts §3: each key of conditions is answered with its body and since.
func TestConditionsAnswersEachKey(t *testing.T) {
rt := newRuntime(t)
for _, k := range []string{"settings.room_below_pct.invalid", "licence.a.near-limit"} {
rt.seedState(licences.EntryConditions, k, licences.KeptCondition{Body: oc.Condition{Key: k, Headline: "h"},
Since: "2026-11-12T10:00:00Z"})
}
var keys []string
for _, c := range run(t, rt.reader(), "licences_conditions", nil).([]any) {
f := c.(map[string]any)
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
t.Fatalf("condition %v", f)
}
keys = append(keys, f["body"].(map[string]any)["key"].(string))
}
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
t.Fatalf("keys %v", keys)
}
}