okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: replace the generic /quota page with a screen of latest limits, bars and per-limit trails8e3e173

main
5.7 KiB201 linesraw
1package daemon
2
3import (
4	"encoding/json"
5	"fmt"
6	"net"
7	"net/http"
8	"net/http/httptest"
9	"sync/atomic"
10	"testing"
11	"time"
12
13	"albedo/cli/internal/daemon/protocol"
14	"albedo/cli/internal/testwire"
15)
16
17func quotaConnection(t *testing.T, handler http.HandlerFunc) *Connection {
18	t.Helper()
19	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20		if r.URL.Path == "/server" && r.URL.Query().Get("include") != "quota_history" {
21			_, _ = w.Write([]byte(testwire.Server))
22			return
23		}
24		handler(w, r)
25	}))
26	t.Cleanup(server.Close)
27	conn, err := Attach(t.Context(), ConnectionSnapshot{
28		Port:    server.Listener.Addr().(*net.TCPAddr).Port,
29		Token:   "token",
30		Version: ProtocolVersion,
31	}, nil)
32	if err != nil {
33		t.Fatal(err)
34	}
35	t.Cleanup(conn.HTTPClient().CloseIdleConnections)
36	return conn
37}
38
39func observation(seq int64, account, provider, limit string) QuotaObservation {
40	return QuotaObservation{
41		Sequence:   seq,
42		AccountID:  account,
43		Provider:   provider,
44		LimitID:    limit,
45		Label:      "Primary",
46		Status:     "ok",
47		Source:     "poll",
48		ObservedAt: time.UnixMilli(seq * 1000).UTC().Format(time.RFC3339),
49	}
50}
51
52func TestGetQuotaTrailPagingAndFiltering(t *testing.T) {
53	since := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
54	var requestCount atomic.Int32
55
56	conn := quotaConnection(t, func(w http.ResponseWriter, r *http.Request) {
57		q := r.URL.Query()
58		if q.Get("include") != "quota_history" {
59			t.Errorf("expected include=quota_history, got %q", q.Get("include"))
60		}
61		if q.Get("limit") != "200" {
62			t.Errorf("expected limit=200, got %q", q.Get("limit"))
63		}
64		if q.Get("quota_provider") != "hyper" {
65			t.Errorf("expected quota_provider=hyper, got %q", q.Get("quota_provider"))
66		}
67		if q.Get("quota_account") != "charm" {
68			t.Errorf("expected quota_account=charm, got %q", q.Get("quota_account"))
69		}
70		if q.Get("quota_limit") != "primary" {
71			t.Errorf("expected quota_limit=primary, got %q", q.Get("quota_limit"))
72		}
73		if q.Get("quota_since") != since.Format(time.RFC3339Nano) {
74			t.Errorf("expected quota_since=%s, got %q", since.Format(time.RFC3339Nano), q.Get("quota_since"))
75		}
76
77		reqNum := requestCount.Add(1)
78		var page protocol.QuotaPage
79		switch reqNum {
80		case 1:
81			if q.Get("next") != "" {
82				t.Errorf("expected empty next on page 1, got %q", q.Get("next"))
83			}
84			// Server returns newest-first: [seq 30, seq 20]
85			nextCursor := "cursor-page-2"
86			page = protocol.QuotaPage{
87				Items: []QuotaObservation{
88					observation(30, "charm", "hyper", "primary"),
89					observation(20, "charm", "hyper", "primary"),
90				},
91				Next: &nextCursor,
92			}
93		case 2:
94			if q.Get("next") != "cursor-page-2" {
95				t.Errorf("expected next=cursor-page-2 on page 2, got %q", q.Get("next"))
96			}
97			// Page 2 returns seq 10, next is nil
98			page = protocol.QuotaPage{
99				Items: []QuotaObservation{
100					observation(10, "charm", "hyper", "primary"),
101				},
102				Next: nil,
103			}
104		default:
105			t.Fatalf("unexpected request number %d", reqNum)
106		}
107
108		var resp map[string]any
109		_ = json.Unmarshal([]byte(testwire.Server), &resp)
110		resp["quota_history"] = page
111		w.Header().Set("Content-Type", "application/json")
112		_ = json.NewEncoder(w).Encode(resp)
113	})
114
115	results, err := GetQuotaTrail(t.Context(), conn, QuotaTrailQuery{
116		Provider: "hyper",
117		Account:  "charm",
118		Limit:    "primary",
119		Since:    since,
120	})
121	if err != nil {
122		t.Fatalf("unexpected error: %v", err)
123	}
124
125	if int(requestCount.Load()) != 2 {
126		t.Fatalf("expected 2 requests, made %d", requestCount.Load())
127	}
128
129	// Must be returned OLDEST FIRST: [seq 10, seq 20, seq 30]
130	if len(results) != 3 {
131		t.Fatalf("expected 3 results, got %d", len(results))
132	}
133	expectedSeqs := []int64{10, 20, 30}
134	for i, exp := range expectedSeqs {
135		if results[i].Sequence != exp {
136			t.Errorf("result[%d] sequence: expected %d, got %d", i, exp, results[i].Sequence)
137		}
138	}
139}
140
141func TestGetQuotaTrailCapsAtFivePages(t *testing.T) {
142	var requestCount atomic.Int32
143
144	conn := quotaConnection(t, func(w http.ResponseWriter, r *http.Request) {
145		reqNum := requestCount.Add(1)
146		nextCursor := fmt.Sprintf("cursor-%d", reqNum+1)
147		page := protocol.QuotaPage{
148			Items: []QuotaObservation{
149				observation(int64(100-reqNum), "acc", "prov", "lim"),
150			},
151			Next: &nextCursor,
152		}
153
154		var resp map[string]any
155		_ = json.Unmarshal([]byte(testwire.Server), &resp)
156		resp["quota_history"] = page
157		w.Header().Set("Content-Type", "application/json")
158		_ = json.NewEncoder(w).Encode(resp)
159	})
160
161	results, err := GetQuotaTrail(t.Context(), conn, QuotaTrailQuery{
162		Provider: "prov",
163		Account:  "acc",
164		Limit:    "lim",
165	})
166	if err != nil {
167		t.Fatalf("unexpected error: %v", err)
168	}
169
170	// Must stop at 5 pages
171	if requestCount.Load() != 5 {
172		t.Fatalf("expected exactly 5 requests, got %d", requestCount.Load())
173	}
174	if len(results) != 5 {
175		t.Fatalf("expected 5 results, got %d", len(results))
176	}
177	// Oldest first: sequences were [99, 98, 97, 96, 95] returned newest first,
178	// so reversed should be [95, 96, 97, 98, 99].
179	expectedSeqs := []int64{95, 96, 97, 98, 99}
180	for i, exp := range expectedSeqs {
181		if results[i].Sequence != exp {
182			t.Errorf("result[%d] sequence: expected %d, got %d", i, exp, results[i].Sequence)
183		}
184	}
185}
186
187func TestGetQuotaTrailWithoutSinceSendsNoBound(t *testing.T) {
188	conn := quotaConnection(t, func(w http.ResponseWriter, r *http.Request) {
189		if r.URL.Query().Has("quota_since") {
190			t.Errorf("a zero Since must not be sent, got quota_since=%q", r.URL.Query().Get("quota_since"))
191		}
192		var resp map[string]any
193		_ = json.Unmarshal([]byte(testwire.Server), &resp)
194		resp["quota_history"] = protocol.QuotaPage{Items: []QuotaObservation{}}
195		w.Header().Set("Content-Type", "application/json")
196		_ = json.NewEncoder(w).Encode(resp)
197	})
198	if _, err := GetQuotaTrail(t.Context(), conn, QuotaTrailQuery{Provider: "p", Account: "a", Limit: "l"}); err != nil {
199		t.Fatal(err)
200	}
201}