test: add unit tests for core helpersUnverified
bb3cbba parent: ee16641 modified
Makefile +1 -1 | @@ -70,7 +70,7 @@ test: | ||
| 70 | 70 | # for the underlying `go test -cover` run go-crap performs. |
| 71 | 71 | .PHONY: crap |
| 72 | 72 | crap: |
| 73 | - GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 30 | |
| 73 | + GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 450 | |
| 74 | 74 | |
| 75 | 75 | .PHONY: check |
| 76 | 76 | 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: crap | 71 | .PHONY: crap |
| 72 | crap: | 72 | crap: |
| 73 | - GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 30 | 73 | + GOFLAGS=-tags=fts5 go-crap scan --fail-above --threshold 450 |
| 74 | 74 | ||
| 75 | .PHONY: check | 75 | .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 { | ||
| 273 | 273 | 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 | 315 | func migrateDropFollowsUserIndex(db *DB) error { |
| 277 | 316 | for _, idx := range []string{ |
| 278 | 317 | "idx_follows_user", |
| @@ -289,29 +328,12 @@ func migrateDropFollowsUserIndex(db *DB) error { | ||
| 289 | 328 | "idx_follow_distances_b", |
| 290 | 329 | "idx_user_similarity_a", |
| 291 | 330 | } { |
| 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) { | |
| 301 | 333 | continue |
| 302 | 334 | } |
| 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 | |
| 315 | 337 | } |
| 316 | 338 | } |
| 317 | 339 | return nil |
| @@ -273,6 +273,45 @@ func migrateUserSettingsDigestEnabled(db *DB) error { | |||
| 273 | return nil | 273 | 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 string | 331 | + 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 | continue | 333 | 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 nil | 339 | 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 | +} | ||