From e9d4a336eac17ddd0fb14be1682fae35ecb4d16b Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Mon, 15 Jun 2026 15:02:17 -0700 Subject: [PATCH] tests(handlers): add some happy path tests everywhere --- internal/api/handlers/brokers_test.go | 29 ++ internal/api/handlers/channels_test.go | 32 +++ internal/api/handlers/iatas_test.go | 52 ++++ internal/api/handlers/nodes_test.go | 35 +++ internal/api/handlers/observers_test.go | 46 ++++ internal/api/handlers/regions_test.go | 46 ++++ internal/api/handlers/routes_test.go | 33 +++ internal/api/handlers/scopes_test.go | 44 ++++ internal/api/handlers/stats_test.go | 17 ++ internal/api/handlers/stub_reader_test.go | 308 +++++++++++++++------- internal/api/handlers/traces_test.go | 60 +++++ 11 files changed, 612 insertions(+), 90 deletions(-) create mode 100644 internal/api/handlers/brokers_test.go create mode 100644 internal/api/handlers/iatas_test.go create mode 100644 internal/api/handlers/scopes_test.go create mode 100644 internal/api/handlers/traces_test.go diff --git a/internal/api/handlers/brokers_test.go b/internal/api/handlers/brokers_test.go new file mode 100644 index 0000000..0eea954 --- /dev/null +++ b/internal/api/handlers/brokers_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/ingest" + "github.com/go-chi/chi/v5" +) + +func TestListBrokers_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/brokers", listBrokers([]*ingest.Worker{})) + req := httptest.NewRequest(http.MethodGet, "/brokers", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var result []BrokerStatus + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode: %v", err) + } +} diff --git a/internal/api/handlers/channels_test.go b/internal/api/handlers/channels_test.go index 57da612..360f0da 100644 --- a/internal/api/handlers/channels_test.go +++ b/internal/api/handlers/channels_test.go @@ -4,10 +4,12 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" ) @@ -109,3 +111,33 @@ func TestListChannelMessages_InvalidCursor(t *testing.T) { t.Errorf("expected 400, got %d", w.Code) } } + +func TestListChannels_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/channels", listChannels(stubReader{ + listChannels: func(_ context.Context, _ int32, _ []byte, _ string, _ int64) (api.Page[api.ChannelSummary], error) { + return api.Page[api.ChannelSummary]{Items: []api.ChannelSummary{{ID: 1, ChannelHash: "ab"}}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/channels", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetChannel_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/channels/{channelID}", getChannel(stubReader{ + getChannel: func(_ context.Context, id int32) (*api.Channel, error) { + return &api.Channel{ChannelSummary: api.ChannelSummary{ID: int(id)}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/channels/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/iatas_test.go b/internal/api/handlers/iatas_test.go new file mode 100644 index 0000000..7cd7a6f --- /dev/null +++ b/internal/api/handlers/iatas_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" +) + +func TestListIATAs_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/iatas", listIATAs(stubReader{ + listIATAs: func(_ context.Context) ([]api.IATA, error) { + return []api.IATA{{IATA: "YVR"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/iatas", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var result []api.IATA + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode: %v", err) + } + if len(result) != 1 || result[0].IATA != "YVR" { + t.Errorf("unexpected result: %v", result) + } +} + +func TestGetIATA_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/iatas/{iata}", getIATA(stubReader{ + getIATA: func(_ context.Context, iata string) (*api.IATA, error) { + return &api.IATA{IATA: iata}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/iatas/YVR", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/nodes_test.go b/internal/api/handlers/nodes_test.go index 7a13927..8f146e9 100644 --- a/internal/api/handlers/nodes_test.go +++ b/internal/api/handlers/nodes_test.go @@ -4,11 +4,14 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) func TestGetNode_InvalidUUID(t *testing.T) { @@ -120,3 +123,35 @@ func TestListNodes_InvalidSupportsMultibyteTraces(t *testing.T) { t.Errorf("expected 400, got %d", w.Code) } } + +func TestListNodes_OK(t *testing.T) { + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{ + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32) (api.Page[api.NodeSummary], error) { + return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID}}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/nodes", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetNode_OK(t *testing.T) { + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + r := chi.NewRouter() + r.Get("/nodes/{nodeId}", getNode(stubReader{ + getNode: func(_ context.Context, id uuid.UUID) (*api.Node, error) { + return &api.Node{NodeSummary: api.NodeSummary{ID: id}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/nodes/"+nodeID.String(), nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/observers_test.go b/internal/api/handlers/observers_test.go index 47d03f9..7791b8e 100644 --- a/internal/api/handlers/observers_test.go +++ b/internal/api/handlers/observers_test.go @@ -4,11 +4,14 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) func TestGetObserverTelemetry_InvalidUUID(t *testing.T) { @@ -62,3 +65,46 @@ func TestGetObserverTelemetry_InvalidInterval(t *testing.T) { t.Errorf("expected 400, got %d", w.Code) } } + +func TestListObservers_OK(t *testing.T) { + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + r := chi.NewRouter() + r.Get("/observers", listObservers(stubReader{ + listObservers: func(_ context.Context, _ []string, _, _, _, _, _ string, _ int64, _ int32) (api.Page[api.ObserverSummary], error) { + return api.Page[api.ObserverSummary]{Items: []api.ObserverSummary{{ID: observerID}}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/observers", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetObserver_OK(t *testing.T) { + observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + r := chi.NewRouter() + r.Get("/observers/{observerId}", getObserver(stubReader{ + getObserver: func(_ context.Context, id uuid.UUID) (*api.Observer, error) { + return &api.Observer{ObserverSummary: api.ObserverSummary{ID: id}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String(), nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetObserver_InvalidUUID(t *testing.T) { + r := chi.NewRouter() + r.Get("/observers/{observerId}", getObserver(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/observers/not-a-uuid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} diff --git a/internal/api/handlers/regions_test.go b/internal/api/handlers/regions_test.go index 56d0a3c..ea4e1b2 100644 --- a/internal/api/handlers/regions_test.go +++ b/internal/api/handlers/regions_test.go @@ -4,9 +4,14 @@ package handlers import ( + "context" "net/http" + "net/http/httptest" "net/url" "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" ) func TestParseIATAs_Single(t *testing.T) { @@ -63,3 +68,44 @@ func TestParseIATAs_Uppercase(t *testing.T) { t.Errorf("expected [YVR], got %v", result) } } + +func TestListRegions_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/regions", listRegions(stubReader{ + listRegions: func(_ context.Context) ([]api.RegionSummary, error) { + return []api.RegionSummary{{ID: 1, Slug: "bc", Name: "British Columbia"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/regions", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetRegion_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/regions/{regionId}", getRegion(stubReader{ + getRegion: func(_ context.Context, id int32) (*api.Region, error) { + return &api.Region{RegionSummary: api.RegionSummary{ID: int(id), Slug: "bc"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/regions/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetRegion_InvalidID(t *testing.T) { + r := chi.NewRouter() + r.Get("/regions/{regionId}", getRegion(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/regions/notanint", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} diff --git a/internal/api/handlers/routes_test.go b/internal/api/handlers/routes_test.go index a1ca9a9..e07abb8 100644 --- a/internal/api/handlers/routes_test.go +++ b/internal/api/handlers/routes_test.go @@ -4,10 +4,13 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "testing" + "time" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" ) @@ -56,3 +59,33 @@ func TestSearchCrossIATARoutes_MissingParams(t *testing.T) { } } } + +func TestListKnownRoutes_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/routes", listKnownRoutes(stubReader{ + listKnownRoutes: func(_ context.Context, _ string, _ int32, _ time.Time, _ int32) ([]api.KnownRoute, error) { + return []api.KnownRoute{{IATA: "YVR"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/routes", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestSearchKnownRoutes_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/routes/search", searchKnownRoutes(stubReader{ + searchKnownRoutes: func(_ context.Context, _, _, _ string) ([]api.KnownRoute, error) { + return []api.KnownRoute{{IATA: "YVR"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/routes/search?iata=YVR&from=aa&to=bb", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/scopes_test.go b/internal/api/handlers/scopes_test.go new file mode 100644 index 0000000..29abb39 --- /dev/null +++ b/internal/api/handlers/scopes_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" +) + +func TestListScopes_NoIATAs_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/scopes", listScopes(stubReader{ + getScopeNames: func(_ context.Context) ([]string, error) { + return []string{"#bc", "#west"}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/scopes", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetScope_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/scopes/{name}", getScope(stubReader{ + getScopeByName: func(_ context.Context, name string) (*api.ScopeDetail, error) { + return &api.ScopeDetail{Name: name}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/scopes/%23bc", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/stats_test.go b/internal/api/handlers/stats_test.go index c161078..91fa32c 100644 --- a/internal/api/handlers/stats_test.go +++ b/internal/api/handlers/stats_test.go @@ -4,10 +4,12 @@ package handlers import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" ) @@ -65,3 +67,18 @@ func TestGetStatsTopObservers_InvalidLimit(t *testing.T) { t.Errorf("expected 400, got %d", w.Code) } } + +func TestGetStatsOverview_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/stats/overview", getStatsOverview(stubReader{ + getStatsOverview: func(_ context.Context, _ []string) (*api.StatsOverview, error) { + return &api.StatsOverview{TotalPackets: 100}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/stats/overview", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index 76a7fd3..c4f7d0d 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -11,175 +11,303 @@ import ( "github.com/google/uuid" ) -// stubReader satisfies api.Reader with zero-value returns. -// Use it for handler tests that exercise validation paths where -// the reader is never actually called. -type stubReader struct{} - -func (stubReader) ListIATAs(ctx context.Context) ([]api.IATA, error) { - return nil, nil +// stubReader satisfies api.Reader with configurable function fields. +// Unset fields return zero values. Use it for both validation tests +// (leave all fields nil) and happy path tests (set only what you need). +type stubReader struct { + listIATAs func(ctx context.Context) ([]api.IATA, error) + getIATA func(ctx context.Context, iata string) (*api.IATA, error) + listRegions func(ctx context.Context) ([]api.RegionSummary, error) + getRegion func(ctx context.Context, regionID int32) (*api.Region, error) + getRegionBySlug func(ctx context.Context, slug string) (*api.Region, error) + listChannels func(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) + getChannel func(ctx context.Context, channelID int32) (*api.Channel, error) + listChannelMessages func(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) + listChannelMessagesByHash func(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) + listMessagesAfterID func(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) + listObservers func(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) + getObserver func(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) + getObserverTelemetry func(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) + getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) + getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error) + listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) + listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) + getNode func(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) + getNodeNeighbors func(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) + listNodeObservations func(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) + listPackets func(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) + listPacketsAfterID func(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) + getPacket func(ctx context.Context, packetHash []byte) (*api.Packet, error) + getRadioPresets func(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) + getStatsOverview func(ctx context.Context, iatas []string) (*api.StatsOverview, error) + getStatsObservations func(ctx context.Context, iatas []string, since time.Time) ([]api.ObservationPoint, error) + getStatsPayloadBreakdown func(ctx context.Context, iatas []string, since time.Time) ([]api.PayloadBreakdownItem, error) + getStatsTopNodes func(ctx context.Context, iatas []string, limit int32) ([]api.TopNode, error) + getStatsTopObservers func(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopObserver, error) + getScopeStats func(ctx context.Context) ([]api.ScopeStats, error) + getStatsNodeTypes func(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) + getScopeNames func(ctx context.Context) ([]string, error) + getScopesByIATAs func(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) + getScopeByName func(ctx context.Context, name string) (*api.ScopeDetail, error) + listTraceTags func(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) + getTraceByTag func(ctx context.Context, tag string) (*api.TraceDetail, error) + listKnownRoutes func(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) + searchKnownRoutes func(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) + getKnownRoutesByNode func(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) + getCrossIATANeighbors func(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) + searchCrossIATARoutes func(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) + getNodesByIDs func(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) } -func (stubReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) { +func (s stubReader) ListIATAs(ctx context.Context) ([]api.IATA, error) { + if s.listIATAs != nil { + return s.listIATAs(ctx) + } return nil, nil } - -func (stubReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) { +func (s stubReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) { + if s.getIATA != nil { + return s.getIATA(ctx, iata) + } return nil, nil } - -func (stubReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) { +func (s stubReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) { + if s.listRegions != nil { + return s.listRegions(ctx) + } return nil, nil } - -func (stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) { +func (s stubReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) { + if s.getRegion != nil { + return s.getRegion(ctx, regionID) + } return nil, nil } - -func (stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) { +func (s stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) { + if s.getRegionBySlug != nil { + return s.getRegionBySlug(ctx, slug) + } + return nil, nil +} +func (s stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) { + if s.listChannels != nil { + return s.listChannels(ctx, limit, hash, iata, cursor) + } return api.Page[api.ChannelSummary]{}, nil } - -func (stubReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) { +func (s stubReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) { + if s.getChannel != nil { + return s.getChannel(ctx, channelID) + } return nil, nil } - -func (stubReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { +func (s stubReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { + if s.listChannelMessages != nil { + return s.listChannelMessages(ctx, channelID, since, limit, iatas, scope, cursor) + } return api.Page[api.ChannelMessage]{}, nil } - -func (stubReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { +func (s stubReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { + if s.listChannelMessagesByHash != nil { + return s.listChannelMessagesByHash(ctx, hash, since, limit, iatas, scope, cursor) + } return api.Page[api.ChannelMessage]{}, nil } - -func (stubReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) { +func (s stubReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) { + if s.listMessagesAfterID != nil { + return s.listMessagesAfterID(ctx, afterID, iatas, scope, limit) + } return nil, nil } - -func (stubReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) { +func (s stubReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) { + if s.listObservers != nil { + return s.listObservers(ctx, iatas, observerType, broker, status, name, scope, cursor, limit) + } return api.Page[api.ObserverSummary]{}, nil } - -func (stubReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) { +func (s stubReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) { + if s.getObserver != nil { + return s.getObserver(ctx, observerID) + } return nil, nil } - -func (stubReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) { +func (s stubReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) { + if s.getObserverTelemetry != nil { + return s.getObserverTelemetry(ctx, observerID, since, until, afterID) + } return nil, nil } - -func (stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) { +func (s stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) { + if s.getObserverTelemetryBucketed != nil { + return s.getObserverTelemetryBucketed(ctx, observerID, since, until, bucketHours) + } return nil, nil } - -func (stubReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) { +func (s stubReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) { + if s.getObserverScopes != nil { + return s.getObserverScopes(ctx, observerID) + } return nil, nil } - -func (stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) { +func (s stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) { + if s.listObserverAdverts != nil { + return s.listObserverAdverts(ctx, observerID, cursor, limit) + } return api.Page[api.AdvertObservation]{}, nil } - -func (stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { +func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { + if s.listNodes != nil { + return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit) + } return api.Page[api.NodeSummary]{}, nil } - -func (stubReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) { +func (s stubReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) { + if s.getNode != nil { + return s.getNode(ctx, nodeID) + } return nil, nil } - -func (stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) { +func (s stubReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) { + if s.getNodeNeighbors != nil { + return s.getNodeNeighbors(ctx, nodeID) + } + return nil, nil +} +func (s stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) { + if s.listNodeObservations != nil { + return s.listNodeObservations(ctx, nodeID, cursor, limit) + } return api.Page[api.PacketObservationSummary]{}, nil } - -func (stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { +func (s stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { + if s.listPackets != nil { + return s.listPackets(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit) + } return api.Page[api.PacketSummary]{}, nil } - -func (stubReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) { +func (s stubReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) { + if s.listPacketsAfterID != nil { + return s.listPacketsAfterID(ctx, afterObservationID, payloadType, routeType, iatas, scope, limit) + } return nil, nil } - -func (stubReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) { +func (s stubReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) { + if s.getPacket != nil { + return s.getPacket(ctx, packetHash) + } return nil, nil } - -func (stubReader) GetRadioPresets(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) { +func (s stubReader) GetRadioPresets(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) { + if s.getRadioPresets != nil { + return s.getRadioPresets(ctx, preset, iatas) + } return nil, nil } - -func (stubReader) GetStatsOverview(ctx context.Context, iatas []string) (*api.StatsOverview, error) { +func (s stubReader) GetStatsOverview(ctx context.Context, iatas []string) (*api.StatsOverview, error) { + if s.getStatsOverview != nil { + return s.getStatsOverview(ctx, iatas) + } return nil, nil } - -func (stubReader) GetStatsObservations(ctx context.Context, iatas []string, since time.Time) ([]api.ObservationPoint, error) { +func (s stubReader) GetStatsObservations(ctx context.Context, iatas []string, since time.Time) ([]api.ObservationPoint, error) { + if s.getStatsObservations != nil { + return s.getStatsObservations(ctx, iatas, since) + } return nil, nil } - -func (stubReader) GetStatsPayloadBreakdown(ctx context.Context, iatas []string, since time.Time) ([]api.PayloadBreakdownItem, error) { +func (s stubReader) GetStatsPayloadBreakdown(ctx context.Context, iatas []string, since time.Time) ([]api.PayloadBreakdownItem, error) { + if s.getStatsPayloadBreakdown != nil { + return s.getStatsPayloadBreakdown(ctx, iatas, since) + } return nil, nil } - -func (stubReader) GetStatsTopNodes(ctx context.Context, iatas []string, limit int32) ([]api.TopNode, error) { +func (s stubReader) GetStatsTopNodes(ctx context.Context, iatas []string, limit int32) ([]api.TopNode, error) { + if s.getStatsTopNodes != nil { + return s.getStatsTopNodes(ctx, iatas, limit) + } return nil, nil } - -func (stubReader) GetStatsTopObservers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopObserver, error) { +func (s stubReader) GetStatsTopObservers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopObserver, error) { + if s.getStatsTopObservers != nil { + return s.getStatsTopObservers(ctx, iatas, since, limit) + } return nil, nil } - -func (stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) { +func (s stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) { + if s.getScopeStats != nil { + return s.getScopeStats(ctx) + } return nil, nil } - -func (stubReader) GetStatsNodeTypes(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) { +func (s stubReader) GetStatsNodeTypes(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) { + if s.getStatsNodeTypes != nil { + return s.getStatsNodeTypes(ctx, iatas) + } return nil, nil } - -func (stubReader) GetScopeNames(ctx context.Context) ([]string, error) { +func (s stubReader) GetScopeNames(ctx context.Context) ([]string, error) { + if s.getScopeNames != nil { + return s.getScopeNames(ctx) + } return nil, nil } - -func (stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { +func (s stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { + if s.getScopesByIATAs != nil { + return s.getScopesByIATAs(ctx, iatas) + } return nil, nil } - -func (stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { +func (s stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { + if s.getScopeByName != nil { + return s.getScopeByName(ctx, name) + } return nil, nil } - -func (stubReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) { +func (s stubReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) { + if s.listTraceTags != nil { + return s.listTraceTags(ctx, iatas, scope, traceType, since, until, cursor, limit) + } return nil, nil } - -func (stubReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) { +func (s stubReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) { + if s.getTraceByTag != nil { + return s.getTraceByTag(ctx, tag) + } return nil, nil } - -func (stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) { +func (s stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) { + if s.listKnownRoutes != nil { + return s.listKnownRoutes(ctx, iata, hopCount, cursor, limit) + } return nil, nil } - -func (stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { +func (s stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { + if s.searchKnownRoutes != nil { + return s.searchKnownRoutes(ctx, iata, fromHash, toHash) + } return nil, nil } - -func (stubReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) { +func (s stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) { + if s.getKnownRoutesByNode != nil { + return s.getKnownRoutesByNode(ctx, iata, nodeID) + } return nil, nil } - -func (stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) { +func (s stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) { + if s.getCrossIATANeighbors != nil { + return s.getCrossIATANeighbors(ctx, nodeID, iata) + } return nil, nil } - -func (stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) { +func (s stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) { + if s.searchCrossIATARoutes != nil { + return s.searchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA) + } return nil, nil } - -func (stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) { - return nil, nil -} - -func (stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) { +func (s stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) { + if s.getNodesByIDs != nil { + return s.getNodesByIDs(ctx, ids) + } return nil, nil } diff --git a/internal/api/handlers/traces_test.go b/internal/api/handlers/traces_test.go new file mode 100644 index 0000000..722d8d3 --- /dev/null +++ b/internal/api/handlers/traces_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" +) + +func TestListTraceTags_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/traces", listTraceTags(stubReader{ + listTraceTags: func(_ context.Context, _ []string, _, _ string, _, _ time.Time, _ time.Time, _ int32) ([]api.TraceTagSummary, error) { + return []api.TraceTagSummary{{TraceTag: "trace-001"}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/traces", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetTrace_OK(t *testing.T) { + r := chi.NewRouter() + r.Get("/traces/{tag}", getTrace(stubReader{ + getTraceByTag: func(_ context.Context, tag string) (*api.TraceDetail, error) { + return &api.TraceDetail{TraceTag: tag}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/traces/trace-001", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetTrace_NotFound(t *testing.T) { + r := chi.NewRouter() + r.Get("/traces/{tag}", getTrace(stubReader{ + getTraceByTag: func(_ context.Context, _ string) (*api.TraceDetail, error) { + return nil, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/traces/missing", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +}