mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-15 13:21:42 +00:00
test(known-channels): failing tests for catalogue cache + route (#1323)
RED commit. Tests assert: - parseKnownChannelsJSON populates GeneratedAt/License + region-stamped entries - /api/known-channels?region=be returns 200 with 2 filtered entries - cache survives upstream 500 (fail-soft: last-known snapshot preserved) Stub implementations return zero/empty so tests run to assertion failure instead of compile errors.
This commit is contained in:
@@ -154,6 +154,13 @@ type Config struct {
|
||||
// Customizer controls operator-side knobs for the in-app customizer modal
|
||||
// (theme/branding/etc.). See CustomizerConfig and issue #1508.
|
||||
Customizer *CustomizerConfig `json:"customizer,omitempty"`
|
||||
|
||||
// Known-channels catalogue integration (issue #1323).
|
||||
// URL of a JSON catalogue file (channels-by-country shape) fetched
|
||||
// periodically and exposed via /api/known-channels. Empty disables.
|
||||
KnownChannelsURL string `json:"knownChannelsUrl,omitempty"`
|
||||
// Refresh interval in milliseconds. 0/missing => default 24h.
|
||||
KnownChannelsRefreshMs int64 `json:"knownChannelsRefreshMs,omitempty"`
|
||||
}
|
||||
|
||||
// CustomizerConfig holds operator-side knobs for the in-app customizer modal.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
// Known-channels catalogue cache (issue #1323).
|
||||
//
|
||||
// Fetches a community-maintained catalogue of hashtag channels (default:
|
||||
// https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json)
|
||||
// every N hours into an in-memory snapshot. Never blocks startup; never
|
||||
// blocks UI on the fetch; fail-soft to last-known. No DB, no disk cache.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultKnownChannelsURL is the pinned upstream catalogue (channels-by-country.json).
|
||||
const DefaultKnownChannelsURL = "https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json"
|
||||
|
||||
// DefaultKnownChannelsRefresh is the default refresh interval (24h).
|
||||
const DefaultKnownChannelsRefresh = 24 * time.Hour
|
||||
|
||||
// KnownChannelEntry is one catalogue entry, region-stamped.
|
||||
type KnownChannelEntry struct {
|
||||
Channel string `json:"channel"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Region string `json:"region"`
|
||||
RegionName string `json:"regionName,omitempty"`
|
||||
}
|
||||
|
||||
// KnownChannelsSnapshot is the immutable parsed catalogue surfaced over /api.
|
||||
type KnownChannelsSnapshot struct {
|
||||
GeneratedAt string `json:"generatedAt,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Source string `json:"source"`
|
||||
Entries []KnownChannelEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// parseKnownChannelsJSON parses the upstream JSON into a snapshot.
|
||||
// STUB — to be implemented in green commit.
|
||||
func parseKnownChannelsJSON(raw []byte, source string, now time.Time) (*KnownChannelsSnapshot, error) {
|
||||
return &KnownChannelsSnapshot{
|
||||
FetchedAt: now,
|
||||
Source: source,
|
||||
Entries: []KnownChannelEntry{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterSnapshotByRegion returns a copy filtered to the given region.
|
||||
// STUB — to be implemented in green commit.
|
||||
func filterSnapshotByRegion(snap *KnownChannelsSnapshot, region string) *KnownChannelsSnapshot {
|
||||
return snap
|
||||
}
|
||||
|
||||
// knownChannelsCache holds the atomic snapshot pointer + config.
|
||||
type knownChannelsCache struct {
|
||||
ptr atomic.Pointer[KnownChannelsSnapshot]
|
||||
url string
|
||||
refresh time.Duration
|
||||
client *http.Client
|
||||
|
||||
fetchCount atomic.Int64
|
||||
failCount atomic.Int64
|
||||
}
|
||||
|
||||
func newKnownChannelsCache(url string, refresh time.Duration) *knownChannelsCache {
|
||||
if refresh <= 0 {
|
||||
refresh = DefaultKnownChannelsRefresh
|
||||
}
|
||||
return &knownChannelsCache{
|
||||
url: url,
|
||||
refresh: refresh,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *knownChannelsCache) load() *KnownChannelsSnapshot {
|
||||
return c.ptr.Load()
|
||||
}
|
||||
|
||||
// fetchOnce — STUB. Returns error so tests fail until implemented.
|
||||
func (c *knownChannelsCache) fetchOnce(ctx context.Context) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (c *knownChannelsCache) run(ctx context.Context) {
|
||||
// stub: no-op
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Canned fixture mirroring the upstream channels-by-country.json shape
|
||||
// (https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json
|
||||
// pinned 2026-05-24). Two countries: one with entries, one empty (to test
|
||||
// the "skip empty countries" branch).
|
||||
const knownChannelsFixture = `{
|
||||
"generated_at": "2026-05-24T22:29:02Z",
|
||||
"license": "CC0-1.0",
|
||||
"countries": {
|
||||
"be": [
|
||||
{"channel": "#antwerpen", "description": "antwerpen"},
|
||||
{"channel": "#bemesh", "description": "bemesh"}
|
||||
],
|
||||
"us": [
|
||||
{"channel": "#bayarea", "description": "Bay Area"}
|
||||
],
|
||||
"ad": []
|
||||
}
|
||||
}`
|
||||
|
||||
// (a) Cache parses a canned JSON fixture into a snapshot.
|
||||
func TestKnownChannelsParseFixture(t *testing.T) {
|
||||
snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Unix(1700000000, 0))
|
||||
if err != nil {
|
||||
t.Fatalf("parseKnownChannelsJSON: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("snapshot is nil")
|
||||
}
|
||||
if snap.GeneratedAt != "2026-05-24T22:29:02Z" {
|
||||
t.Errorf("GeneratedAt = %q, want 2026-05-24T22:29:02Z", snap.GeneratedAt)
|
||||
}
|
||||
if snap.License != "CC0-1.0" {
|
||||
t.Errorf("License = %q, want CC0-1.0", snap.License)
|
||||
}
|
||||
if snap.Source != "fixture://test" {
|
||||
t.Errorf("Source = %q, want fixture://test", snap.Source)
|
||||
}
|
||||
if got, want := len(snap.Entries), 3; got != want {
|
||||
t.Fatalf("len(Entries) = %d, want %d (empty country ad must be skipped)", got, want)
|
||||
}
|
||||
// Spot-check one entry's region stamping.
|
||||
var foundAntwerpen bool
|
||||
for _, e := range snap.Entries {
|
||||
if e.Channel == "#antwerpen" {
|
||||
foundAntwerpen = true
|
||||
if e.Region != "be" {
|
||||
t.Errorf("antwerpen Region = %q, want be", e.Region)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAntwerpen {
|
||||
t.Fatal("antwerpen entry missing from snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// (b) The route returns 200 + filtered list.
|
||||
func TestKnownChannelsRouteRegionFilter(t *testing.T) {
|
||||
snap, err := parseKnownChannelsJSON([]byte(knownChannelsFixture), "fixture://test", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
srv := &Server{
|
||||
knownChannels: &knownChannelsCache{},
|
||||
}
|
||||
srv.knownChannels.ptr.Store(snap)
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/api/known-channels", srv.handleKnownChannels).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/known-channels?region=be", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp KnownChannelsSnapshot
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
if got := len(resp.Entries); got != 2 {
|
||||
t.Fatalf("filtered entries = %d, want 2 (be has 2); got body=%s", got, w.Body.String())
|
||||
}
|
||||
for _, e := range resp.Entries {
|
||||
if e.Region != "be" {
|
||||
t.Errorf("entry %q has region %q, want be", e.Channel, e.Region)
|
||||
}
|
||||
if !strings.HasPrefix(e.Channel, "#") {
|
||||
t.Errorf("entry channel %q missing # prefix", e.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Cache survives upstream 500 (fail-soft): a prior good snapshot must
|
||||
// remain available after a failed refresh.
|
||||
func TestKnownChannelsFailSoftOn500(t *testing.T) {
|
||||
// First server: returns the fixture (success).
|
||||
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(knownChannelsFixture))
|
||||
}))
|
||||
defer good.Close()
|
||||
|
||||
c := newKnownChannelsCache(good.URL, time.Hour)
|
||||
if err := c.fetchOnce(context.Background()); err != nil {
|
||||
t.Fatalf("initial fetchOnce: %v", err)
|
||||
}
|
||||
first := c.load()
|
||||
if first == nil || len(first.Entries) == 0 {
|
||||
t.Fatal("first snapshot must be populated")
|
||||
}
|
||||
|
||||
// Second server: always 500.
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer bad.Close()
|
||||
|
||||
// Re-point the cache to the failing upstream and fetch.
|
||||
c.url = bad.URL
|
||||
err := c.fetchOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected fetchOnce to return error on 500")
|
||||
}
|
||||
after := c.load()
|
||||
if after == nil {
|
||||
t.Fatal("snapshot wiped after failed fetch — must be fail-soft")
|
||||
}
|
||||
if len(after.Entries) != len(first.Entries) {
|
||||
t.Errorf("snapshot entry count changed after failed fetch: was %d, now %d", len(first.Entries), len(after.Entries))
|
||||
}
|
||||
if c.failCount.Load() < 1 {
|
||||
t.Errorf("failCount = %d, want >=1", c.failCount.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleKnownChannels — GET /api/known-channels?region=XX
|
||||
// Returns the cached community catalogue, optionally filtered to one region
|
||||
// (ISO 3166-1 alpha-2, case-insensitive). Empty/missing snapshot returns
|
||||
// 200 with an empty Entries list — fail-soft for the UI. Issue #1323.
|
||||
//
|
||||
// STUB — full implementation lands with the green commit.
|
||||
func (s *Server) handleKnownChannels(w http.ResponseWriter, r *http.Request) {
|
||||
// Stub: always return an empty snapshot (no region filtering, no cache
|
||||
// read). Tests asserting filtered output / non-empty payload fail here.
|
||||
writeJSON(w, &KnownChannelsSnapshot{
|
||||
FetchedAt: time.Time{},
|
||||
Source: "",
|
||||
Entries: []KnownChannelEntry{},
|
||||
})
|
||||
}
|
||||
@@ -89,6 +89,10 @@ type Server struct {
|
||||
// package globals) so multiple instances don't share observable
|
||||
// state. Initialised lazily on first use; see node_reach.go.
|
||||
reach reachState
|
||||
|
||||
// Known-channels catalogue cache (issue #1323). Nil until configured;
|
||||
// when nil the /api/known-channels endpoint returns an empty snapshot.
|
||||
knownChannels *knownChannelsCache
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
@@ -269,6 +273,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
|
||||
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
|
||||
r.HandleFunc("/api/known-channels", s.handleKnownChannels).Methods("GET")
|
||||
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
|
||||
|
||||
Reference in New Issue
Block a user