Files
mesh-catalog/modules/claude-licence-manager/cmd/manager/switching_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

195 lines
7.9 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
. "claude-licence-manager/internal/licences"
)
// read is a licence's latest reading, read at t0.
func read(session, weekly float64) *UsageReading {
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
}
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
func weekly(pct float64) *UsageReading {
u := read(0, pct)
u.SessionPct, u.SessionResetsAt = nil, ""
return u
}
func sub(name string, u *UsageReading) Licence {
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
}
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
func TestTheRuleMovesEachTable(t *testing.T) {
mm := newMesh(t)
on := func(consumers ...string) []Binding {
var out []Binding
for _, c := range consumers {
out = append(out, Binding{Consumer: c, Licence: "a"})
}
return out
}
for _, c := range []struct {
name string
licences []Licence
bindings []Binding
to string
window string
noTarget bool
consumers int
}{
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
on("x", "y", "z", "w"), "c", "weekly", false, 4},
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
on("x"), "", "", false, 0},
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
on("x", "y"), "b", "weekly", false, 2},
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
on("x"), "b", "weekly", false, 1},
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
} {
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
to, window, n := "", "", 0
if r.Switch != nil {
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
}
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
}
}
}
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
func fourOnA(t *testing.T) *miniMesh {
mm := newMesh(t)
ctx := context.Background()
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
_ = mm.store.SaveLicence(ctx, l)
}
for _, c := range []string{"ace", "g14", "laptop", "server"} {
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
}
mm.events = nil
return mm
}
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
func TestOneSwitchedPerAct(t *testing.T) {
mm := fourOnA(t)
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
t.Fatalf("acts %+v %v", acts, err)
}
var switched []map[string]any
changed := 0
for _, e := range mm.events {
name, body, _ := strings.Cut(e, " ")
var f map[string]any
_ = json.Unmarshal([]byte(body), &f)
switch name {
case "licence.switched":
switched = append(switched, f)
case "binding.changed." + fmt.Sprint(f["consumer"]):
changed++
}
}
if len(switched) != 1 || changed != 4 {
t.Fatalf("said %v", mm.events)
}
s := switched[0]
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
t.Fatalf("licence.switched %v", s)
}
audit := mm.audit.under("switched")
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
t.Fatalf("audit %v", audit)
}
b := mm.bound("laptop")
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
t.Fatalf("laptop %+v", b)
}
}
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
mm := fourOnA(t)
var told []string
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
told = append(told, headline+" | "+strings.Join(lines, " "))
return nil
}
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
t.Fatalf("told %v", told)
}
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
if len(told) != 1 {
t.Fatalf("the switch verb told %v", told)
}
}
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
}
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
t.Fatalf("dry %+v, act %+v", dry, acts)
}
for name, change := range map[string]func(*Settings){
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
} {
mm := fourOnA(t)
change(&mm.m.Settings)
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
t.Errorf("%s moved %+v", name, acts)
}
}
mm = fourOnA(t)
mm.m.Settings.SwitchAuto = false
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("by hand with switch_auto off moved %+v", acts)
}
}
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
func TestAReadingAtTheThresholdApplies(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = 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"}}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
t.Fatalf("laptop %+v", b)
}
}