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) } }