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:
Kpa-clawbot
2026-06-11 12:25:16 +00:00
parent 1116801b2f
commit 5c43cff342
5 changed files with 273 additions and 0 deletions
+7
View File
@@ -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.
+91
View File
@@ -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
}
+148
View File
@@ -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())
}
}
+22
View File
@@ -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{},
})
}
+5
View File
@@ -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")