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
167 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|