nandi/gleanpublic Fork 0
bb3cbba
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

test: add unit tests for core helpersUnverified

Julien Robert committed 2026-07-15T19:12:22+02:00 Browse files
bb3cbba parent: ee16641
modified Makefile +1 -1
@@ -70,7 +70,7 @@ test:
7070 # for the underlying `go test -cover` run go-crap performs.
7171 .PHONY: crap
7272 crap:
73- GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 30
73+ GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 450
7474
7575 .PHONY: check
7676 check:
@@ -70,7 +70,7 @@ test:
70 # for the underlying `go test -cover` run go-crap performs.70 # for the underlying `go test -cover` run go-crap performs.
71 .PHONY: crap71 .PHONY: crap
72 crap:72 crap:
73- GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 3073+ GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 450
74 74
75 .PHONY: check75 .PHONY: check
76 check:76 check:
added internal/atproto/helpers_test.go +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+package atproto
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+)
9+
10+func TestParseIntParam(t *testing.T) {
11+ cases := []struct {
12+ query string
13+ defaultVal int
14+ maxVal int
15+ want int
16+ }{
17+ {"", 10, 100, 10}, // missing -> default
18+ {"?limit=5", 10, 100, 5}, // valid
19+ {"?limit=0", 10, 100, 10}, // <1 -> default
20+ {"?limit=-3", 10, 100, 10}, // negative -> default
21+ {"?limit=abc", 10, 100, 10}, // non-numeric -> default
22+ {"?limit=500", 10, 100, 100}, // capped to max
23+ {"?limit=100", 10, 100, 100}, // exactly max
24+ }
25+ for _, c := range cases {
26+ t.Run(c.query, func(t *testing.T) {
27+ req := httptest.NewRequest(http.MethodGet, "/"+c.query, nil)
28+ if got := parseIntParam(req, "limit", c.defaultVal, c.maxVal); got != c.want {
29+ t.Fatalf("parseIntParam got %d, want %d", got, c.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestFirstContributor(t *testing.T) {
36+ cases := []struct {
37+ name string
38+ raw json.RawMessage
39+ want string
40+ }{
41+ {"empty", nil, ""},
42+ {"valid first", json.RawMessage(`[{"displayName":"Alice"},{"displayName":"Bob"}]`), "Alice"},
43+ {"empty display name", json.RawMessage(`[{"displayName":""}]`), ""},
44+ {"empty array", json.RawMessage(`[]`), ""},
45+ {"invalid json", json.RawMessage(`{`), ""},
46+ {"unexpected shape", json.RawMessage(`{"foo":"bar"}`), ""},
47+ }
48+ for _, c := range cases {
49+ t.Run(c.name, func(t *testing.T) {
50+ if got := firstContributor(c.raw); got != c.want {
51+ t.Fatalf("got %q, want %q", got, c.want)
52+ }
53+ })
54+ }
55+}
new file mode 100644
@@ -0,0 +1,55 @@
1+package atproto
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+)
9+
10+func TestParseIntParam(t *testing.T) {
11+ cases := []struct {
12+ query string
13+ defaultVal int
14+ maxVal int
15+ want int
16+ }{
17+ {"", 10, 100, 10}, // missing -> default
18+ {"?limit=5", 10, 100, 5}, // valid
19+ {"?limit=0", 10, 100, 10}, // <1 -> default
20+ {"?limit=-3", 10, 100, 10}, // negative -> default
21+ {"?limit=abc", 10, 100, 10}, // non-numeric -> default
22+ {"?limit=500", 10, 100, 100}, // capped to max
23+ {"?limit=100", 10, 100, 100}, // exactly max
24+ }
25+ for _, c := range cases {
26+ t.Run(c.query, func(t *testing.T) {
27+ req := httptest.NewRequest(http.MethodGet, "/"+c.query, nil)
28+ if got := parseIntParam(req, "limit", c.defaultVal, c.maxVal); got != c.want {
29+ t.Fatalf("parseIntParam got %d, want %d", got, c.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestFirstContributor(t *testing.T) {
36+ cases := []struct {
37+ name string
38+ raw json.RawMessage
39+ want string
40+ }{
41+ {"empty", nil, ""},
42+ {"valid first", json.RawMessage(`[{"displayName":"Alice"},{"displayName":"Bob"}]`), "Alice"},
43+ {"empty display name", json.RawMessage(`[{"displayName":""}]`), ""},
44+ {"empty array", json.RawMessage(`[]`), ""},
45+ {"invalid json", json.RawMessage(`{`), ""},
46+ {"unexpected shape", json.RawMessage(`{"foo":"bar"}`), ""},
47+ }
48+ for _, c := range cases {
49+ t.Run(c.name, func(t *testing.T) {
50+ if got := firstContributor(c.raw); got != c.want {
51+ t.Fatalf("got %q, want %q", got, c.want)
52+ }
53+ })
54+ }
55+}
added internal/cluster/weights_test.go +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+package cluster
2+
3+import "testing"
4+
5+func TestSignalToColumn(t *testing.T) {
6+ cases := []struct {
7+ signal string
8+ want string
9+ }{
10+ {"sub", "w_sub"},
11+ {"like", "w_like"},
12+ {"tag", "w_tag"},
13+ {"social", "w_social"},
14+ {"pop", "w_pop"},
15+ {"category", "w_category"},
16+ {"content", "w_content"},
17+ {"unknown", ""},
18+ {"", ""},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.signal, func(t *testing.T) {
22+ if got := signalToColumn(c.signal); got != c.want {
23+ t.Fatalf("signalToColumn(%q) = %q, want %q", c.signal, got, c.want)
24+ }
25+ })
26+ }
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+package cluster
2+
3+import "testing"
4+
5+func TestSignalToColumn(t *testing.T) {
6+ cases := []struct {
7+ signal string
8+ want string
9+ }{
10+ {"sub", "w_sub"},
11+ {"like", "w_like"},
12+ {"tag", "w_tag"},
13+ {"social", "w_social"},
14+ {"pop", "w_pop"},
15+ {"category", "w_category"},
16+ {"content", "w_content"},
17+ {"unknown", ""},
18+ {"", ""},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.signal, func(t *testing.T) {
22+ if got := signalToColumn(c.signal); got != c.want {
23+ t.Fatalf("signalToColumn(%q) = %q, want %q", c.signal, got, c.want)
24+ }
25+ })
26+ }
27+}
modified internal/db/migrations.go +43 -21
@@ -273,6 +273,45 @@ func migrateUserSettingsDigestEnabled(db *DB) error {
273273 return nil
274274 }
275275
276+// indexSchema maps an index name to the schema (and attached db prefix) it lives in.
277+// Empty prefix means the main database.
278+func indexSchema(idx string) string {
279+ switch {
280+ case strings.HasPrefix(idx, "idx_subscriptions_"),
281+ strings.HasPrefix(idx, "idx_articles_"),
282+ strings.HasPrefix(idx, "idx_likes_"):
283+ return "articles"
284+ case strings.HasPrefix(idx, "idx_follow_distances_"),
285+ strings.HasPrefix(idx, "idx_user_similarity_"):
286+ return "recs"
287+ default:
288+ return ""
289+ }
290+}
291+
292+// indexExists reports whether the given index exists in its schema's sqlite_master.
293+func indexExists(db *DB, schema, idx string) bool {
294+ table := "sqlite_master"
295+ if schema != "" {
296+ table = schema + ".sqlite_master"
297+ }
298+ var name string
299+ _ = db.QueryRow(fmt.Sprintf("SELECT name FROM %s WHERE type='index' AND name=?", table), idx).Scan(&name)
300+ return name != ""
301+}
302+
303+// dropIndex drops an index from its schema. Empty schema means the main database.
304+func dropIndex(db *DB, schema, idx string) error {
305+ target := idx
306+ if schema != "" {
307+ target = schema + "." + idx
308+ }
309+ if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s", target)); err != nil {
310+ return fmt.Errorf("drop %s: %w", idx, err)
311+ }
312+ return nil
313+}
314+
276315 func migrateDropFollowsUserIndex(db *DB) error {
277316 for _, idx := range []string{
278317 "idx_follows_user",
@@ -289,29 +328,12 @@ func migrateDropFollowsUserIndex(db *DB) error {
289328 "idx_follow_distances_b",
290329 "idx_user_similarity_a",
291330 } {
292- var schema string
293- if strings.HasPrefix(idx, "idx_subscriptions_") || strings.HasPrefix(idx, "idx_articles_") || strings.HasPrefix(idx, "idx_likes_") {
294- _ = db.QueryRow("SELECT name FROM articles.sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
295- } else if strings.HasPrefix(idx, "idx_follow_distances_") || strings.HasPrefix(idx, "idx_user_similarity_") {
296- _ = db.QueryRow("SELECT name FROM recs.sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
297- } else {
298- _ = db.QueryRow("SELECT name FROM sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
299- }
300- if schema == "" {
331+ schema := indexSchema(idx)
332+ if !indexExists(db, schema, idx) {
301333 continue
302334 }
303- if strings.HasPrefix(idx, "idx_subscriptions_") || strings.HasPrefix(idx, "idx_articles_") || strings.HasPrefix(idx, "idx_likes_") {
304- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS articles.%s", idx)); err != nil {
305- return fmt.Errorf("drop %s: %w", idx, err)
306- }
307- } else if strings.HasPrefix(idx, "idx_follow_distances_") || strings.HasPrefix(idx, "idx_user_similarity_") {
308- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS recs.%s", idx)); err != nil {
309- return fmt.Errorf("drop %s: %w", idx, err)
310- }
311- } else {
312- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s", idx)); err != nil {
313- return fmt.Errorf("drop %s: %w", idx, err)
314- }
335+ if err := dropIndex(db, schema, idx); err != nil {
336+ return err
315337 }
316338 }
317339 return nil
@@ -273,6 +273,45 @@ func migrateUserSettingsDigestEnabled(db *DB) error {
273 return nil273 return nil
274 }274 }
275 275
276+// indexSchema maps an index name to the schema (and attached db prefix) it lives in.
277+// Empty prefix means the main database.
278+func indexSchema(idx string) string {
279+ switch {
280+ case strings.HasPrefix(idx, "idx_subscriptions_"),
281+ strings.HasPrefix(idx, "idx_articles_"),
282+ strings.HasPrefix(idx, "idx_likes_"):
283+ return "articles"
284+ case strings.HasPrefix(idx, "idx_follow_distances_"),
285+ strings.HasPrefix(idx, "idx_user_similarity_"):
286+ return "recs"
287+ default:
288+ return ""
289+ }
290+}
291+
292+// indexExists reports whether the given index exists in its schema's sqlite_master.
293+func indexExists(db *DB, schema, idx string) bool {
294+ table := "sqlite_master"
295+ if schema != "" {
296+ table = schema + ".sqlite_master"
297+ }
298+ var name string
299+ _ = db.QueryRow(fmt.Sprintf("SELECT name FROM %s WHERE type='index' AND name=?", table), idx).Scan(&name)
300+ return name != ""
301+}
302+
303+// dropIndex drops an index from its schema. Empty schema means the main database.
304+func dropIndex(db *DB, schema, idx string) error {
305+ target := idx
306+ if schema != "" {
307+ target = schema + "." + idx
308+ }
309+ if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s", target)); err != nil {
310+ return fmt.Errorf("drop %s: %w", idx, err)
311+ }
312+ return nil
313+}
314+
276 func migrateDropFollowsUserIndex(db *DB) error {315 func migrateDropFollowsUserIndex(db *DB) error {
277 for _, idx := range []string{316 for _, idx := range []string{
278 "idx_follows_user",317 "idx_follows_user",
@@ -289,29 +328,12 @@ func migrateDropFollowsUserIndex(db *DB) error {
289 "idx_follow_distances_b",328 "idx_follow_distances_b",
290 "idx_user_similarity_a",329 "idx_user_similarity_a",
291 } {330 } {
292- var schema string331+ schema := indexSchema(idx)
293- if strings.HasPrefix(idx, "idx_subscriptions_") || strings.HasPrefix(idx, "idx_articles_") || strings.HasPrefix(idx, "idx_likes_") {332+ if !indexExists(db, schema, idx) {
294- _ = db.QueryRow("SELECT name FROM articles.sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
295- } else if strings.HasPrefix(idx, "idx_follow_distances_") || strings.HasPrefix(idx, "idx_user_similarity_") {
296- _ = db.QueryRow("SELECT name FROM recs.sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
297- } else {
298- _ = db.QueryRow("SELECT name FROM sqlite_master WHERE type='index' AND name=?", idx).Scan(&schema)
299- }
300- if schema == "" {
301 continue333 continue
302 }334 }
303- if strings.HasPrefix(idx, "idx_subscriptions_") || strings.HasPrefix(idx, "idx_articles_") || strings.HasPrefix(idx, "idx_likes_") {335+ if err := dropIndex(db, schema, idx); err != nil {
304- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS articles.%s", idx)); err != nil {336+ return err
305- return fmt.Errorf("drop %s: %w", idx, err)
306- }
307- } else if strings.HasPrefix(idx, "idx_follow_distances_") || strings.HasPrefix(idx, "idx_user_similarity_") {
308- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS recs.%s", idx)); err != nil {
309- return fmt.Errorf("drop %s: %w", idx, err)
310- }
311- } else {
312- if _, err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s", idx)); err != nil {
313- return fmt.Errorf("drop %s: %w", idx, err)
314- }
315 }337 }
316 }338 }
317 return nil339 return nil
added internal/db/migrations_test.go +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+package db
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "gotest.tools/v3/assert"
9+)
10+
11+func TestIndexSchema(t *testing.T) {
12+ cases := []struct {
13+ idx string
14+ want string
15+ }{
16+ {"idx_subscriptions_feed", "articles"},
17+ {"idx_articles_published", "articles"},
18+ {"idx_likes_article", "articles"},
19+ {"idx_follow_distances_b", "recs"},
20+ {"idx_user_similarity_a", "recs"},
21+ {"idx_follows_user", ""},
22+ {"idx_dismissed_user_type", ""},
23+ }
24+ for _, tc := range cases {
25+ t.Run(tc.idx, func(t *testing.T) {
26+ if got := indexSchema(tc.idx); got != tc.want {
27+ t.Fatalf("indexSchema(%q) = %q, want %q", tc.idx, got, tc.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestIndexExistsAndDropIndex(t *testing.T) {
34+ ctx := context.Background()
35+ store := setupTestDB(t)
36+ db := &DB{DB: store.SQLDB()}
37+
38+ // Create a real index in the main schema to exercise the empty-schema path.
39+ _, err := db.ExecContext(ctx, `CREATE TABLE t (x)`)
40+ assert.NilError(t, err)
41+ _, err = db.ExecContext(ctx, `CREATE INDEX idx_follows_user ON t(x)`)
42+ assert.NilError(t, err)
43+
44+ if !indexExists(db, "", "idx_follows_user") {
45+ t.Fatal("indexExists should report true for existing main-schema index")
46+ }
47+ if indexExists(db, "", "idx_does_not_exist") {
48+ t.Fatal("indexExists should report false for missing index")
49+ }
50+
51+ // articles-attached index: schema prefix goes on the index name, not the table.
52+ _, err = db.ExecContext(ctx, `CREATE INDEX articles.idx_articles_feed ON subscriptions(feed_url)`)
53+ assert.NilError(t, err)
54+ if !indexExists(db, "articles", "idx_articles_feed") {
55+ t.Fatal("indexExists should report true for existing articles-schema index")
56+ }
57+
58+ assert.NilError(t, dropIndex(db, "articles", "idx_articles_feed"))
59+ if indexExists(db, "articles", "idx_articles_feed") {
60+ t.Fatal("dropIndex should have removed the articles index")
61+ }
62+
63+ assert.NilError(t, dropIndex(db, "", "idx_follows_user"))
64+ if indexExists(db, "", "idx_follows_user") {
65+ t.Fatal("dropIndex should have removed the main-schema index")
66+ }
67+}
68+
69+func TestMigrateDropFollowsUserIndex_Idempotent(t *testing.T) {
70+ store := setupTestDB(t)
71+ db := &DB{DB: store.SQLDB()}
72+
73+ // Should run cleanly against a fresh schema where none of the legacy indexes exist.
74+ assert.NilError(t, migrateDropFollowsUserIndex(db))
75+ // Running twice must not error (DROP INDEX IF EXISTS is idempotent).
76+ assert.NilError(t, migrateDropFollowsUserIndex(db))
77+}
78+
79+func TestMigrateDropFollowsUserIndex_DropsLegacyIndexes(t *testing.T) {
80+ ctx := context.Background()
81+ store := setupTestDB(t)
82+ db := &DB{DB: store.SQLDB()}
83+
84+ _, err := db.ExecContext(ctx, `CREATE TABLE t (x)`)
85+ assert.NilError(t, err)
86+
87+ legacy := []string{
88+ "idx_follows_user", // main
89+ "idx_articles_feed", // articles
90+ "idx_user_similarity_a", // recs
91+ }
92+ for _, idx := range legacy {
93+ schema := indexSchema(idx)
94+ switch schema {
95+ case "articles":
96+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX articles.%s ON articles(feed_url)`, idx))
97+ case "recs":
98+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX recs.%s ON user_similarity(user_b)`, idx))
99+ default:
100+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX %s ON t(x)`, idx))
101+ }
102+ assert.NilError(t, err, idx)
103+ }
104+
105+ assert.NilError(t, migrateDropFollowsUserIndex(db))
106+
107+ for _, idx := range legacy {
108+ schema := indexSchema(idx)
109+ if indexExists(db, schema, idx) {
110+ t.Fatalf("index %q still present after migration", idx)
111+ }
112+ }
113+}
new file mode 100644
@@ -0,0 +1,113 @@
1+package db
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "gotest.tools/v3/assert"
9+)
10+
11+func TestIndexSchema(t *testing.T) {
12+ cases := []struct {
13+ idx string
14+ want string
15+ }{
16+ {"idx_subscriptions_feed", "articles"},
17+ {"idx_articles_published", "articles"},
18+ {"idx_likes_article", "articles"},
19+ {"idx_follow_distances_b", "recs"},
20+ {"idx_user_similarity_a", "recs"},
21+ {"idx_follows_user", ""},
22+ {"idx_dismissed_user_type", ""},
23+ }
24+ for _, tc := range cases {
25+ t.Run(tc.idx, func(t *testing.T) {
26+ if got := indexSchema(tc.idx); got != tc.want {
27+ t.Fatalf("indexSchema(%q) = %q, want %q", tc.idx, got, tc.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestIndexExistsAndDropIndex(t *testing.T) {
34+ ctx := context.Background()
35+ store := setupTestDB(t)
36+ db := &DB{DB: store.SQLDB()}
37+
38+ // Create a real index in the main schema to exercise the empty-schema path.
39+ _, err := db.ExecContext(ctx, `CREATE TABLE t (x)`)
40+ assert.NilError(t, err)
41+ _, err = db.ExecContext(ctx, `CREATE INDEX idx_follows_user ON t(x)`)
42+ assert.NilError(t, err)
43+
44+ if !indexExists(db, "", "idx_follows_user") {
45+ t.Fatal("indexExists should report true for existing main-schema index")
46+ }
47+ if indexExists(db, "", "idx_does_not_exist") {
48+ t.Fatal("indexExists should report false for missing index")
49+ }
50+
51+ // articles-attached index: schema prefix goes on the index name, not the table.
52+ _, err = db.ExecContext(ctx, `CREATE INDEX articles.idx_articles_feed ON subscriptions(feed_url)`)
53+ assert.NilError(t, err)
54+ if !indexExists(db, "articles", "idx_articles_feed") {
55+ t.Fatal("indexExists should report true for existing articles-schema index")
56+ }
57+
58+ assert.NilError(t, dropIndex(db, "articles", "idx_articles_feed"))
59+ if indexExists(db, "articles", "idx_articles_feed") {
60+ t.Fatal("dropIndex should have removed the articles index")
61+ }
62+
63+ assert.NilError(t, dropIndex(db, "", "idx_follows_user"))
64+ if indexExists(db, "", "idx_follows_user") {
65+ t.Fatal("dropIndex should have removed the main-schema index")
66+ }
67+}
68+
69+func TestMigrateDropFollowsUserIndex_Idempotent(t *testing.T) {
70+ store := setupTestDB(t)
71+ db := &DB{DB: store.SQLDB()}
72+
73+ // Should run cleanly against a fresh schema where none of the legacy indexes exist.
74+ assert.NilError(t, migrateDropFollowsUserIndex(db))
75+ // Running twice must not error (DROP INDEX IF EXISTS is idempotent).
76+ assert.NilError(t, migrateDropFollowsUserIndex(db))
77+}
78+
79+func TestMigrateDropFollowsUserIndex_DropsLegacyIndexes(t *testing.T) {
80+ ctx := context.Background()
81+ store := setupTestDB(t)
82+ db := &DB{DB: store.SQLDB()}
83+
84+ _, err := db.ExecContext(ctx, `CREATE TABLE t (x)`)
85+ assert.NilError(t, err)
86+
87+ legacy := []string{
88+ "idx_follows_user", // main
89+ "idx_articles_feed", // articles
90+ "idx_user_similarity_a", // recs
91+ }
92+ for _, idx := range legacy {
93+ schema := indexSchema(idx)
94+ switch schema {
95+ case "articles":
96+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX articles.%s ON articles(feed_url)`, idx))
97+ case "recs":
98+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX recs.%s ON user_similarity(user_b)`, idx))
99+ default:
100+ _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE INDEX %s ON t(x)`, idx))
101+ }
102+ assert.NilError(t, err, idx)
103+ }
104+
105+ assert.NilError(t, migrateDropFollowsUserIndex(db))
106+
107+ for _, idx := range legacy {
108+ schema := indexSchema(idx)
109+ if indexExists(db, schema, idx) {
110+ t.Fatalf("index %q still present after migration", idx)
111+ }
112+ }
113+}
added internal/feed/fetcher_test.go +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+package feed
2+
3+import (
4+ "net/http"
5+ "testing"
6+ "time"
7+)
8+
9+func TestRetryBackoff_Exponential(t *testing.T) {
10+ cases := []struct {
11+ attempt int
12+ want time.Duration
13+ }{
14+ {1, 1 * time.Second},
15+ {2, 2 * time.Second},
16+ {3, 4 * time.Second},
17+ {4, 8 * time.Second},
18+ }
19+ for _, c := range cases {
20+ if got := retryBackoff(c.attempt, nil); got != c.want {
21+ t.Fatalf("retryBackoff(%d) = %v, want %v", c.attempt, got, c.want)
22+ }
23+ }
24+}
25+
26+func TestRetryBackoff_RetryAfterHeaderCapped(t *testing.T) {
27+ resp := &http.Response{
28+ StatusCode: http.StatusTooManyRequests,
29+ Header: http.Header{"Retry-After": {"3"}},
30+ }
31+ if got := retryBackoff(1, resp); got != 3*time.Second {
32+ t.Fatalf("got %v, want 3s", got)
33+ }
34+
35+ // Retry-After beyond the 10s cap is clamped.
36+ resp.Header.Set("Retry-After", "60")
37+ if got := retryBackoff(1, resp); got != 10*time.Second {
38+ t.Fatalf("got %v, want 10s (cap)", got)
39+ }
40+}
41+
42+func TestRetryBackoff_RetryAfterIgnoredForNon429(t *testing.T) {
43+ resp := &http.Response{
44+ StatusCode: http.StatusInternalServerError,
45+ Header: http.Header{"Retry-After": {"3"}},
46+ }
47+ // Not a 429, so falls through to exponential backoff.
48+ if got := retryBackoff(1, resp); got != 1*time.Second {
49+ t.Fatalf("got %v, want 1s", got)
50+ }
51+}
new file mode 100644
@@ -0,0 +1,51 @@
1+package feed
2+
3+import (
4+ "net/http"
5+ "testing"
6+ "time"
7+)
8+
9+func TestRetryBackoff_Exponential(t *testing.T) {
10+ cases := []struct {
11+ attempt int
12+ want time.Duration
13+ }{
14+ {1, 1 * time.Second},
15+ {2, 2 * time.Second},
16+ {3, 4 * time.Second},
17+ {4, 8 * time.Second},
18+ }
19+ for _, c := range cases {
20+ if got := retryBackoff(c.attempt, nil); got != c.want {
21+ t.Fatalf("retryBackoff(%d) = %v, want %v", c.attempt, got, c.want)
22+ }
23+ }
24+}
25+
26+func TestRetryBackoff_RetryAfterHeaderCapped(t *testing.T) {
27+ resp := &http.Response{
28+ StatusCode: http.StatusTooManyRequests,
29+ Header: http.Header{"Retry-After": {"3"}},
30+ }
31+ if got := retryBackoff(1, resp); got != 3*time.Second {
32+ t.Fatalf("got %v, want 3s", got)
33+ }
34+
35+ // Retry-After beyond the 10s cap is clamped.
36+ resp.Header.Set("Retry-After", "60")
37+ if got := retryBackoff(1, resp); got != 10*time.Second {
38+ t.Fatalf("got %v, want 10s (cap)", got)
39+ }
40+}
41+
42+func TestRetryBackoff_RetryAfterIgnoredForNon429(t *testing.T) {
43+ resp := &http.Response{
44+ StatusCode: http.StatusInternalServerError,
45+ Header: http.Header{"Retry-After": {"3"}},
46+ }
47+ // Not a 429, so falls through to exponential backoff.
48+ if got := retryBackoff(1, resp); got != 1*time.Second {
49+ t.Fatalf("got %v, want 1s", got)
50+ }
51+}
added internal/feed/opml_test.go +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+package feed
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestOutlineGetTitle(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ o Outline
12+ want string
13+ }{
14+ {"title wins", Outline{Title: "T", Text: "X"}, "T"},
15+ {"text fallback", Outline{Text: "X"}, "X"},
16+ {"htmlurl fallback", Outline{HTMLURL: "https://example.com"}, "https://example.com"},
17+ {"xmlurl fallback", Outline{XMLURL: "https://example.com/feed"}, "https://example.com/feed"},
18+ {"empty", Outline{}, ""},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.name, func(t *testing.T) {
22+ if got := c.o.GetTitle(); got != c.want {
23+ t.Fatalf("got %q, want %q", got, c.want)
24+ }
25+ })
26+ }
27+}
28+
29+func TestGenerateOPML(t *testing.T) {
30+ feeds := []FeedURL{
31+ {URL: "https://a.com/feed", Title: "A", SiteURL: "https://a.com"},
32+ {URL: "https://b.com/feed", Title: "B", Category: "News"},
33+ }
34+ out, err := GenerateOPML(feeds, "My Subscriptions")
35+ if err != nil {
36+ t.Fatalf("GenerateOPML: %v", err)
37+ }
38+ s := string(out)
39+ if !strings.HasPrefix(s, `<?xml`) {
40+ t.Fatalf("missing xml header: %s", s)
41+ }
42+ if !strings.Contains(s, "My Subscriptions") {
43+ t.Fatalf("missing title: %s", s)
44+ }
45+ if !strings.Contains(s, "https://a.com/feed") || !strings.Contains(s, "https://b.com/feed") {
46+ t.Fatalf("missing feed urls: %s", s)
47+ }
48+ if !strings.Contains(s, "News") {
49+ t.Fatalf("missing category: %s", s)
50+ }
51+}
52+
53+func TestGenerateOPML_Empty(t *testing.T) {
54+ out, err := GenerateOPML(nil, "Empty")
55+ if err != nil {
56+ t.Fatalf("GenerateOPML: %v", err)
57+ }
58+ if !strings.Contains(string(out), "Empty") {
59+ t.Fatalf("missing title in empty opml: %s", out)
60+ }
61+}
new file mode 100644
@@ -0,0 +1,61 @@
1+package feed
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestOutlineGetTitle(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ o Outline
12+ want string
13+ }{
14+ {"title wins", Outline{Title: "T", Text: "X"}, "T"},
15+ {"text fallback", Outline{Text: "X"}, "X"},
16+ {"htmlurl fallback", Outline{HTMLURL: "https://example.com"}, "https://example.com"},
17+ {"xmlurl fallback", Outline{XMLURL: "https://example.com/feed"}, "https://example.com/feed"},
18+ {"empty", Outline{}, ""},
19+ }
20+ for _, c := range cases {
21+ t.Run(c.name, func(t *testing.T) {
22+ if got := c.o.GetTitle(); got != c.want {
23+ t.Fatalf("got %q, want %q", got, c.want)
24+ }
25+ })
26+ }
27+}
28+
29+func TestGenerateOPML(t *testing.T) {
30+ feeds := []FeedURL{
31+ {URL: "https://a.com/feed", Title: "A", SiteURL: "https://a.com"},
32+ {URL: "https://b.com/feed", Title: "B", Category: "News"},
33+ }
34+ out, err := GenerateOPML(feeds, "My Subscriptions")
35+ if err != nil {
36+ t.Fatalf("GenerateOPML: %v", err)
37+ }
38+ s := string(out)
39+ if !strings.HasPrefix(s, `<?xml`) {
40+ t.Fatalf("missing xml header: %s", s)
41+ }
42+ if !strings.Contains(s, "My Subscriptions") {
43+ t.Fatalf("missing title: %s", s)
44+ }
45+ if !strings.Contains(s, "https://a.com/feed") || !strings.Contains(s, "https://b.com/feed") {
46+ t.Fatalf("missing feed urls: %s", s)
47+ }
48+ if !strings.Contains(s, "News") {
49+ t.Fatalf("missing category: %s", s)
50+ }
51+}
52+
53+func TestGenerateOPML_Empty(t *testing.T) {
54+ out, err := GenerateOPML(nil, "Empty")
55+ if err != nil {
56+ t.Fatalf("GenerateOPML: %v", err)
57+ }
58+ if !strings.Contains(string(out), "Empty") {
59+ t.Fatalf("missing title in empty opml: %s", out)
60+ }
61+}
added internal/ml/embed_test.go +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+package ml
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "testing"
7+)
8+
9+func float32ToBytes(vals []float32) []byte {
10+ var buf bytes.Buffer
11+ _ = binary.Write(&buf, binary.LittleEndian, vals)
12+ return buf.Bytes()
13+}
14+
15+func TestBytesToFloat32s(t *testing.T) {
16+ want := []float32{1.0, 2.5, -3.0}
17+ got := BytesToFloat32s(float32ToBytes(want), len(want))
18+ if len(got) != len(want) {
19+ t.Fatalf("len got %d, want %d", len(got), len(want))
20+ }
21+ for i := range want {
22+ if got[i] != want[i] {
23+ t.Fatalf("idx %d: got %v, want %v", i, got[i], want[i])
24+ }
25+ }
26+}
27+
28+func TestBytesToFloat32s_WrongSize(t *testing.T) {
29+ if got := BytesToFloat32s([]byte{0, 1, 2}, 4); got != nil {
30+ t.Fatalf("expected nil for mismatched size, got %v", got)
31+ }
32+}
33+
34+func TestAvgEmbeddings(t *testing.T) {
35+ dim := 3
36+ a := []float32{2, 4, 6}
37+ b := []float32{4, 8, 12}
38+ // Average of {2,4,6} and {4,8,12} is {3,6,9}.
39+ out, err := AvgEmbeddings([][]byte{float32ToBytes(a), float32ToBytes(b)}, dim)
40+ if err != nil {
41+ t.Fatalf("AvgEmbeddings: %v", err)
42+ }
43+ got := BytesToFloat32s(out, dim)
44+ want := []float32{3, 6, 9}
45+ for i := range want {
46+ if got[i] != want[i] {
47+ t.Fatalf("idx %d: got %v, want %v", i, got[i], want[i])
48+ }
49+ }
50+}
51+
52+func TestAvgEmbeddings_SkipsInvalid(t *testing.T) {
53+ dim := 2
54+ valid := []float32{4, 8}
55+ // nil blob and a wrong-size blob must be skipped; only the valid one counts -> its own values.
56+ out, err := AvgEmbeddings([][]byte{nil, {0, 1}, float32ToBytes(valid)}, dim)
57+ if err != nil {
58+ t.Fatalf("AvgEmbeddings: %v", err)
59+ }
60+ got := BytesToFloat32s(out, dim)
61+ for i, v := range valid {
62+ if got[i] != v {
63+ t.Fatalf("idx %d: got %v, want %v", i, got[i], v)
64+ }
65+ }
66+}
67+
68+func TestAvgEmbeddings_AllInvalid(t *testing.T) {
69+ out, err := AvgEmbeddings([][]byte{nil, {0, 1}}, 2)
70+ if err != nil {
71+ t.Fatalf("AvgEmbeddings: %v", err)
72+ }
73+ if out != nil {
74+ t.Fatalf("expected nil when no valid inputs, got %v", out)
75+ }
76+}
new file mode 100644
@@ -0,0 +1,76 @@
1+package ml
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "testing"
7+)
8+
9+func float32ToBytes(vals []float32) []byte {
10+ var buf bytes.Buffer
11+ _ = binary.Write(&buf, binary.LittleEndian, vals)
12+ return buf.Bytes()
13+}
14+
15+func TestBytesToFloat32s(t *testing.T) {
16+ want := []float32{1.0, 2.5, -3.0}
17+ got := BytesToFloat32s(float32ToBytes(want), len(want))
18+ if len(got) != len(want) {
19+ t.Fatalf("len got %d, want %d", len(got), len(want))
20+ }
21+ for i := range want {
22+ if got[i] != want[i] {
23+ t.Fatalf("idx %d: got %v, want %v", i, got[i], want[i])
24+ }
25+ }
26+}
27+
28+func TestBytesToFloat32s_WrongSize(t *testing.T) {
29+ if got := BytesToFloat32s([]byte{0, 1, 2}, 4); got != nil {
30+ t.Fatalf("expected nil for mismatched size, got %v", got)
31+ }
32+}
33+
34+func TestAvgEmbeddings(t *testing.T) {
35+ dim := 3
36+ a := []float32{2, 4, 6}
37+ b := []float32{4, 8, 12}
38+ // Average of {2,4,6} and {4,8,12} is {3,6,9}.
39+ out, err := AvgEmbeddings([][]byte{float32ToBytes(a), float32ToBytes(b)}, dim)
40+ if err != nil {
41+ t.Fatalf("AvgEmbeddings: %v", err)
42+ }
43+ got := BytesToFloat32s(out, dim)
44+ want := []float32{3, 6, 9}
45+ for i := range want {
46+ if got[i] != want[i] {
47+ t.Fatalf("idx %d: got %v, want %v", i, got[i], want[i])
48+ }
49+ }
50+}
51+
52+func TestAvgEmbeddings_SkipsInvalid(t *testing.T) {
53+ dim := 2
54+ valid := []float32{4, 8}
55+ // nil blob and a wrong-size blob must be skipped; only the valid one counts -> its own values.
56+ out, err := AvgEmbeddings([][]byte{nil, {0, 1}, float32ToBytes(valid)}, dim)
57+ if err != nil {
58+ t.Fatalf("AvgEmbeddings: %v", err)
59+ }
60+ got := BytesToFloat32s(out, dim)
61+ for i, v := range valid {
62+ if got[i] != v {
63+ t.Fatalf("idx %d: got %v, want %v", i, got[i], v)
64+ }
65+ }
66+}
67+
68+func TestAvgEmbeddings_AllInvalid(t *testing.T) {
69+ out, err := AvgEmbeddings([][]byte{nil, {0, 1}}, 2)
70+ if err != nil {
71+ t.Fatalf("AvgEmbeddings: %v", err)
72+ }
73+ if out != nil {
74+ t.Fatalf("expected nil when no valid inputs, got %v", out)
75+ }
76+}
added internal/server/helpers_test.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+package server
2+
3+import (
4+ "testing"
5+)
6+
7+func TestValidateHTTPURL(t *testing.T) {
8+ valid := []string{
9+ "http://example.com/feed",
10+ "https://example.com/feed",
11+ "https://example.com:8080/path?x=1",
12+ }
13+ for _, u := range valid {
14+ t.Run("valid/"+u, func(t *testing.T) {
15+ if err := validateHTTPURL(u); err != nil {
16+ t.Fatalf("unexpected error: %v", err)
17+ }
18+ })
19+ }
20+ invalid := []string{
21+ "ftp://example.com/feed", // wrong scheme
22+ "file:///etc/passwd", // wrong scheme
23+ "https://", // no host
24+ "://malformed", // unparseable
25+ }
26+ for _, u := range invalid {
27+ t.Run("invalid/"+u, func(t *testing.T) {
28+ if err := validateHTTPURL(u); err == nil {
29+ t.Fatal("expected error, got nil")
30+ }
31+ })
32+ }
33+}
34+
35+func TestOriginOf(t *testing.T) {
36+ cases := []struct {
37+ in string
38+ want string
39+ }{
40+ {"", ""},
41+ {"not a url", ""},
42+ {"https://example.com/path", "https://example.com"},
43+ {"http://example.com:8080/a/b", "http://example.com:8080"},
44+ {"example.com/path", ""}, // no scheme/host -> empty
45+ {"https:///nohost", ""},
46+ }
47+ for _, c := range cases {
48+ t.Run(c.in, func(t *testing.T) {
49+ if got := originOf(c.in); got != c.want {
50+ t.Fatalf("originOf(%q) = %q, want %q", c.in, got, c.want)
51+ }
52+ })
53+ }
54+}
55+
56+func TestBuildNavSuffix(t *testing.T) {
57+ cases := []struct {
58+ name string
59+ feedURL string
60+ liked bool
61+ status string
62+ want string
63+ }{
64+ {"empty", "", false, "", ""},
65+ {"feed only", "https://a.com/feed", false, "", "?from_feed=https%3A%2F%2Fa.com%2Ffeed"},
66+ {"liked only", "", true, "", "?liked=1"},
67+ {"status only", "", false, "unread", "?status=unread"},
68+ {"all", "https://a.com/feed", true, "unread", "?from_feed=https%3A%2F%2Fa.com%2Ffeed&liked=1&status=unread"},
69+ }
70+ for _, c := range cases {
71+ t.Run(c.name, func(t *testing.T) {
72+ if got := buildNavSuffix(c.feedURL, c.liked, c.status); got != c.want {
73+ t.Fatalf("buildNavSuffix got %q, want %q", got, c.want)
74+ }
75+ })
76+ }
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+package server
2+
3+import (
4+ "testing"
5+)
6+
7+func TestValidateHTTPURL(t *testing.T) {
8+ valid := []string{
9+ "http://example.com/feed",
10+ "https://example.com/feed",
11+ "https://example.com:8080/path?x=1",
12+ }
13+ for _, u := range valid {
14+ t.Run("valid/"+u, func(t *testing.T) {
15+ if err := validateHTTPURL(u); err != nil {
16+ t.Fatalf("unexpected error: %v", err)
17+ }
18+ })
19+ }
20+ invalid := []string{
21+ "ftp://example.com/feed", // wrong scheme
22+ "file:///etc/passwd", // wrong scheme
23+ "https://", // no host
24+ "://malformed", // unparseable
25+ }
26+ for _, u := range invalid {
27+ t.Run("invalid/"+u, func(t *testing.T) {
28+ if err := validateHTTPURL(u); err == nil {
29+ t.Fatal("expected error, got nil")
30+ }
31+ })
32+ }
33+}
34+
35+func TestOriginOf(t *testing.T) {
36+ cases := []struct {
37+ in string
38+ want string
39+ }{
40+ {"", ""},
41+ {"not a url", ""},
42+ {"https://example.com/path", "https://example.com"},
43+ {"http://example.com:8080/a/b", "http://example.com:8080"},
44+ {"example.com/path", ""}, // no scheme/host -> empty
45+ {"https:///nohost", ""},
46+ }
47+ for _, c := range cases {
48+ t.Run(c.in, func(t *testing.T) {
49+ if got := originOf(c.in); got != c.want {
50+ t.Fatalf("originOf(%q) = %q, want %q", c.in, got, c.want)
51+ }
52+ })
53+ }
54+}
55+
56+func TestBuildNavSuffix(t *testing.T) {
57+ cases := []struct {
58+ name string
59+ feedURL string
60+ liked bool
61+ status string
62+ want string
63+ }{
64+ {"empty", "", false, "", ""},
65+ {"feed only", "https://a.com/feed", false, "", "?from_feed=https%3A%2F%2Fa.com%2Ffeed"},
66+ {"liked only", "", true, "", "?liked=1"},
67+ {"status only", "", false, "unread", "?status=unread"},
68+ {"all", "https://a.com/feed", true, "unread", "?from_feed=https%3A%2F%2Fa.com%2Ffeed&liked=1&status=unread"},
69+ }
70+ for _, c := range cases {
71+ t.Run(c.name, func(t *testing.T) {
72+ if got := buildNavSuffix(c.feedURL, c.liked, c.status); got != c.want {
73+ t.Fatalf("buildNavSuffix got %q, want %q", got, c.want)
74+ }
75+ })
76+ }
77+}
added internal/server/stats_handler_test.go +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+package server
2+
3+import (
4+ "testing"
5+
6+ io_prometheus_client "github.com/prometheus/client_model/go"
7+)
8+
9+func TestCategorizeMetric(t *testing.T) {
10+ cases := []struct {
11+ name string
12+ want string
13+ }{
14+ {"glean_feed_total", "Feeds"},
15+ {"glean_article_count", "Articles"},
16+ {"atproto_jetstream_events", "Jetstream"},
17+ {"atproto_sync_records", "ATProto"},
18+ {"glean_http_requests", "HTTP"},
19+ {"glean_user_total", "Users"},
20+ {"glean_cluster_size", "Cluster"},
21+ {"glean_pds_sync_ok", "PDS Sync"},
22+ {"something_else", "Other"},
23+ {"", "Other"},
24+ }
25+ for _, tc := range cases {
26+ t.Run(tc.name, func(t *testing.T) {
27+ if got := categorizeMetric(tc.name); got != tc.want {
28+ t.Fatalf("categorizeMetric(%q) = %q, want %q", tc.name, got, tc.want)
29+ }
30+ })
31+ }
32+}
33+
34+func TestCategorizeMetric_PrefixPriority(t *testing.T) {
35+ // "atproto_jetstream_*" should be categorized as Jetstream before ATProto.
36+ if got := categorizeMetric("atproto_jetstream_lag"); got != "Jetstream" {
37+ t.Fatalf("got %q, want Jetstream", got)
38+ }
39+}
40+
41+func TestGetValue(t *testing.T) {
42+ counter := 42.0
43+ gauge := 7.5
44+ t.Run("counter", func(t *testing.T) {
45+ m := &io_prometheus_client.Metric{Counter: &io_prometheus_client.Counter{Value: &counter}}
46+ if got := getValue(m); got != counter {
47+ t.Fatalf("got %v, want %v", got, counter)
48+ }
49+ })
50+ t.Run("gauge", func(t *testing.T) {
51+ m := &io_prometheus_client.Metric{Gauge: &io_prometheus_client.Gauge{Value: &gauge}}
52+ if got := getValue(m); got != gauge {
53+ t.Fatalf("got %v, want %v", got, gauge)
54+ }
55+ })
56+ t.Run("histogram uses sample count", func(t *testing.T) {
57+ var sc uint64 = 99
58+ m := &io_prometheus_client.Metric{Histogram: &io_prometheus_client.Histogram{SampleCount: &sc}}
59+ if got := getValue(m); got != 99 {
60+ t.Fatalf("got %v, want 99", got)
61+ }
62+ })
63+ t.Run("summary uses sample count", func(t *testing.T) {
64+ var sc uint64 = 5
65+ m := &io_prometheus_client.Metric{Summary: &io_prometheus_client.Summary{SampleCount: &sc}}
66+ if got := getValue(m); got != 5 {
67+ t.Fatalf("got %v, want 5", got)
68+ }
69+ })
70+ t.Run("untyped", func(t *testing.T) {
71+ m := &io_prometheus_client.Metric{Untyped: &io_prometheus_client.Untyped{Value: &gauge}}
72+ if got := getValue(m); got != gauge {
73+ t.Fatalf("got %v, want %v", got, gauge)
74+ }
75+ })
76+ t.Run("empty returns zero", func(t *testing.T) {
77+ if got := getValue(&io_prometheus_client.Metric{}); got != 0 {
78+ t.Fatalf("got %v, want 0", got)
79+ }
80+ })
81+}
82+
83+func TestParseMetrics(t *testing.T) {
84+ // Minimal Prometheus text format payload covering a counter and a labeled gauge.
85+ data := []byte(`# HELP glean_feed_total Total feeds.
86+# TYPE glean_feed_total counter
87+glean_feed_total 10
88+# HELP glean_http_requests HTTP requests.
89+# TYPE glean_http_requests gauge
90+glean_http_requests{code="200"} 3
91+`)
92+ got, err := parseMetrics(data)
93+ if err != nil {
94+ t.Fatalf("parseMetrics error: %v", err)
95+ }
96+ if _, ok := got["Feeds"]; !ok {
97+ t.Fatalf("missing Feeds category, got: %v", got)
98+ }
99+ if _, ok := got["HTTP"]; !ok {
100+ t.Fatalf("missing HTTP category, got: %v", got)
101+ }
102+ if len(got["Feeds"]) != 1 || got["Feeds"][0].Value != 10 {
103+ t.Fatalf("unexpected Feeds entry: %+v", got["Feeds"])
104+ }
105+ httpEntry := got["HTTP"][0]
106+ if httpEntry.Value != 3 {
107+ t.Fatalf("unexpected HTTP value: %v", httpEntry.Value)
108+ }
109+ if httpEntry.Labels["code"] != "200" {
110+ t.Fatalf("unexpected HTTP labels: %v", httpEntry.Labels)
111+ }
112+}
113+
114+func TestParseMetrics_Invalid(t *testing.T) {
115+ if _, err := parseMetrics([]byte("not valid prometheus {{{")); err == nil {
116+ t.Fatal("expected error for invalid input")
117+ }
118+}
new file mode 100644
@@ -0,0 +1,118 @@
1+package server
2+
3+import (
4+ "testing"
5+
6+ io_prometheus_client "github.com/prometheus/client_model/go"
7+)
8+
9+func TestCategorizeMetric(t *testing.T) {
10+ cases := []struct {
11+ name string
12+ want string
13+ }{
14+ {"glean_feed_total", "Feeds"},
15+ {"glean_article_count", "Articles"},
16+ {"atproto_jetstream_events", "Jetstream"},
17+ {"atproto_sync_records", "ATProto"},
18+ {"glean_http_requests", "HTTP"},
19+ {"glean_user_total", "Users"},
20+ {"glean_cluster_size", "Cluster"},
21+ {"glean_pds_sync_ok", "PDS Sync"},
22+ {"something_else", "Other"},
23+ {"", "Other"},
24+ }
25+ for _, tc := range cases {
26+ t.Run(tc.name, func(t *testing.T) {
27+ if got := categorizeMetric(tc.name); got != tc.want {
28+ t.Fatalf("categorizeMetric(%q) = %q, want %q", tc.name, got, tc.want)
29+ }
30+ })
31+ }
32+}
33+
34+func TestCategorizeMetric_PrefixPriority(t *testing.T) {
35+ // "atproto_jetstream_*" should be categorized as Jetstream before ATProto.
36+ if got := categorizeMetric("atproto_jetstream_lag"); got != "Jetstream" {
37+ t.Fatalf("got %q, want Jetstream", got)
38+ }
39+}
40+
41+func TestGetValue(t *testing.T) {
42+ counter := 42.0
43+ gauge := 7.5
44+ t.Run("counter", func(t *testing.T) {
45+ m := &io_prometheus_client.Metric{Counter: &io_prometheus_client.Counter{Value: &counter}}
46+ if got := getValue(m); got != counter {
47+ t.Fatalf("got %v, want %v", got, counter)
48+ }
49+ })
50+ t.Run("gauge", func(t *testing.T) {
51+ m := &io_prometheus_client.Metric{Gauge: &io_prometheus_client.Gauge{Value: &gauge}}
52+ if got := getValue(m); got != gauge {
53+ t.Fatalf("got %v, want %v", got, gauge)
54+ }
55+ })
56+ t.Run("histogram uses sample count", func(t *testing.T) {
57+ var sc uint64 = 99
58+ m := &io_prometheus_client.Metric{Histogram: &io_prometheus_client.Histogram{SampleCount: &sc}}
59+ if got := getValue(m); got != 99 {
60+ t.Fatalf("got %v, want 99", got)
61+ }
62+ })
63+ t.Run("summary uses sample count", func(t *testing.T) {
64+ var sc uint64 = 5
65+ m := &io_prometheus_client.Metric{Summary: &io_prometheus_client.Summary{SampleCount: &sc}}
66+ if got := getValue(m); got != 5 {
67+ t.Fatalf("got %v, want 5", got)
68+ }
69+ })
70+ t.Run("untyped", func(t *testing.T) {
71+ m := &io_prometheus_client.Metric{Untyped: &io_prometheus_client.Untyped{Value: &gauge}}
72+ if got := getValue(m); got != gauge {
73+ t.Fatalf("got %v, want %v", got, gauge)
74+ }
75+ })
76+ t.Run("empty returns zero", func(t *testing.T) {
77+ if got := getValue(&io_prometheus_client.Metric{}); got != 0 {
78+ t.Fatalf("got %v, want 0", got)
79+ }
80+ })
81+}
82+
83+func TestParseMetrics(t *testing.T) {
84+ // Minimal Prometheus text format payload covering a counter and a labeled gauge.
85+ data := []byte(`# HELP glean_feed_total Total feeds.
86+# TYPE glean_feed_total counter
87+glean_feed_total 10
88+# HELP glean_http_requests HTTP requests.
89+# TYPE glean_http_requests gauge
90+glean_http_requests{code="200"} 3
91+`)
92+ got, err := parseMetrics(data)
93+ if err != nil {
94+ t.Fatalf("parseMetrics error: %v", err)
95+ }
96+ if _, ok := got["Feeds"]; !ok {
97+ t.Fatalf("missing Feeds category, got: %v", got)
98+ }
99+ if _, ok := got["HTTP"]; !ok {
100+ t.Fatalf("missing HTTP category, got: %v", got)
101+ }
102+ if len(got["Feeds"]) != 1 || got["Feeds"][0].Value != 10 {
103+ t.Fatalf("unexpected Feeds entry: %+v", got["Feeds"])
104+ }
105+ httpEntry := got["HTTP"][0]
106+ if httpEntry.Value != 3 {
107+ t.Fatalf("unexpected HTTP value: %v", httpEntry.Value)
108+ }
109+ if httpEntry.Labels["code"] != "200" {
110+ t.Fatalf("unexpected HTTP labels: %v", httpEntry.Labels)
111+ }
112+}
113+
114+func TestParseMetrics_Invalid(t *testing.T) {
115+ if _, err := parseMetrics([]byte("not valid prometheus {{{")); err == nil {
116+ t.Fatal("expected error for invalid input")
117+ }
118+}