Files
mesh-catalog/modules/claude-licence-manager/cmd/manager/tools_test.go
T
jschoubben b1da6344a8 Join 11: merge origin/main (the licence manager in parts, issue 1409)
The conflict in the old cmd/claude-licence-manager/tools_test.go resolves to both sides: main
moved it to cmd/manager, and the join's count of binding.changed.<consumer> under its family
goes into the moved TestResyncSaysEachOnce. The whole-module test's runtime refused
binding.changed.<consumer> because the manifest names the family binding.changed: it now
accepts a key that extends a named family by further parts, as A-134 says the event.

Builds: design 47 G A-134

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 14:05:51 +01:00

167 lines
6.7 KiB
Go

package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// stdTool is one served verb or tool's run.
type stdTool func(map[string]any) (any, error)
// served is every verb and tool the binary serves, by name, run on mm's manager.
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
t.Helper()
built = mm.m
t.Cleanup(func() { built = nil })
out := map[string]stdTool{}
for _, tool := range tools() {
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
}
return out
}
// answer is a tool's answer as JSON, decoded into a generic form.
func answer(t *testing.T, run stdTool, args map[string]any) any {
t.Helper()
got, err := run(args)
if err != nil {
t.Fatalf("%v answered %v", args, err)
}
raw, _ := json.Marshal(got)
var v any
_ = json.Unmarshal(raw, &v)
return v
}
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
// conditions, and no token.
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
mm := fourOnA(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
AdoptedFrom: "a file"})
_, _ = saying(mm)
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
_ = mm.m.SayConditions(ctx, false)
got := answer(t, served(t, mm)["licences"], nil).([]any)
by := map[string]map[string]any{}
for _, l := range got {
f := l.(map[string]any)
by[f["name"].(string)] = f
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
if _, ok := f[k]; !ok {
t.Errorf("%s has no %s: %v", f["name"], k, f)
}
}
}
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
t.Fatalf("licences %v", by)
}
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
t.Fatalf("a's conditions %v", cs)
}
raw, _ := json.Marshal(got)
for _, token := range []string{"rt-", "at-", "local-"} {
if strings.Contains(string(raw), token) {
t.Fatalf("licences holds %s: %s", token, raw)
}
}
}
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, sub("a", nil))
for i := range 30 {
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
map[string]any{"i": float64(i)})
}
run := served(t, mm)["usage"]
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
first := rows[0].(map[string]any)
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
t.Fatalf("by default %d rows, the first %v", len(rows), first)
}
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
t.Fatalf("since and limit 3: %v", rows)
}
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
t.Fatalf("since: %d rows", len(rows))
}
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
t.Fatal("a since that is not a time is answered")
}
}
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
// reads, answers the moves and moves nothing.
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
ctx := context.Background()
for _, dry := range []bool{true, false} {
mm := newMesh(t)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
r := got[0].(map[string]any)
moved := mm.bound("laptop").Licence == "b@example.org"
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
}
}
}
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
// acting tool waits for nothing to be carried over (design 39 parts §11).
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
mm := fourOnA(t)
mm.m.Settings.SwitchAuto = false
run := served(t, mm)["licences_switch_now"]
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
t.Fatalf("dry %v", got)
}
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
}
}
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
// once, and licence-put of each licence's access once.
func TestResyncSaysEachOnce(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, said := saying(mm)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
mm.m.Settings.WarnAt = 100
_ = mm.m.SayConditions(ctx, false)
mm.events, *said = nil, nil
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
count := map[string]int{}
for _, e := range mm.events {
name, _, _ := strings.Cut(e, " ")
if strings.HasPrefix(name, BindingChanged+".") {
name = BindingChanged
}
count[name]++
}
puts, _ := got["licence-put"].([]any)
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
}
}