mesh/delivery merged: source.main moved to novox/mesh-catalog@6d000647de2d
mesh/check being carried to cff1005422c1 on 6d000647de2d | from f9dcbc295723
The one bundle that did everything becomes the manager bundle, which runs long and answers the seat's ten verbs and the three acting tools, and a tools bundle of its own that answers the four reading tools and writes nothing, so they answer while the manager fails to load or stops. Both import the module's one package for the layouts of the kept data and the log entries, the key form, the entries' names and the settings with their validity; the settings file and the account with its directory stay as they are. Builds: design 39 parts §1, §2, §3, §4, §5, §6, §7, §8
164 lines
6.6 KiB
Go
164 lines
6.6 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, " ")
|
|
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)
|
|
}
|
|
}
|