mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-11 06:25:44 +00:00
422 lines
15 KiB
Go
422 lines
15 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func TestGetObserverTelemetry_InvalidUUID(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/not-a-uuid/telemetry", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_InvalidRange(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?range=banana", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_InvalidAfterID(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?afterId=notanint", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_InvalidInterval(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?interval=2h", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_OK(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{
|
|
getObserverTelemetry: func(_ context.Context, _ uuid.UUID, _, _ time.Time, _ int64) (*api.ObserverTelemetry, error) {
|
|
return &api.ObserverTelemetry{Points: []api.ObserverTelemetryPoint{}}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/telemetry", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_Bucketed_OK(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{
|
|
getObserverTelemetryBucketed: func(_ context.Context, _ uuid.UUID, _, _ time.Time, _ int32) ([]api.ObserverTelemetryPoint, error) {
|
|
return []api.ObserverTelemetryPoint{}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/telemetry?interval=6h", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverTelemetry_Bucketed_StoreError(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{
|
|
getObserverTelemetryBucketed: func(_ context.Context, _ uuid.UUID, _, _ time.Time, _ int32) ([]api.ObserverTelemetryPoint, error) {
|
|
return nil, errors.New("boom")
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/telemetry?interval=6h", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
}
|
|
var body map[string]APIError
|
|
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
|
t.Fatalf("expected JSON error body, got %q: %v", w.Body.String(), err)
|
|
}
|
|
if body["error"].Code != "internal_server_error" {
|
|
t.Errorf("expected internal_server_error, got %q", body["error"].Code)
|
|
}
|
|
}
|
|
|
|
func TestListObserverAdverts_OK(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/adverts", listObserverAdverts(stubReader{
|
|
listObserverAdverts: func(_ context.Context, _ uuid.UUID, _ int64, _ int32) (api.Page[api.AdvertObservation], error) {
|
|
return api.Page[api.AdvertObservation]{Items: []api.AdvertObservation{}}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/adverts", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListObserverAdverts_InvalidUUID(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/adverts", listObserverAdverts(stubReader{}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/not-a-uuid/adverts", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_Defaults(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
var gotWindow, gotInterval time.Duration
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
|
gotWindow, gotInterval = window, interval
|
|
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotWindow != 24*time.Hour {
|
|
t.Errorf("expected window 24h, got %s", gotWindow)
|
|
}
|
|
if gotInterval != 15*time.Minute {
|
|
t.Errorf("expected interval 15m, got %s", gotInterval)
|
|
}
|
|
var body api.ObserverActivity
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body.Range != "24h" || body.Interval != "15m" {
|
|
t.Errorf("expected range 24h interval 15m, got %q/%q", body.Range, body.Interval)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_CustomRangeAndInterval(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
var gotWindow, gotInterval time.Duration
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
|
gotWindow, gotInterval = window, interval
|
|
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity?range=168h&interval=1h", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotWindow != 168*time.Hour {
|
|
t.Errorf("expected window 168h, got %s", gotWindow)
|
|
}
|
|
if gotInterval != time.Hour {
|
|
t.Errorf("expected interval 1h, got %s", gotInterval)
|
|
}
|
|
var body api.ObserverActivity
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body.Range != "168h" || body.Interval != "1h" {
|
|
t.Errorf("expected range 168h interval 1h, got %q/%q", body.Range, body.Interval)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_SubHourRangeLimit(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
|
t.Fatal("reader should not be called")
|
|
return nil, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity?range=168h&interval=15m", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var errBody map[string]APIError
|
|
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if errBody["error"].Message != "range must be 48h or less for intervals under 1h" {
|
|
t.Errorf("unexpected error message %q", errBody["error"].Message)
|
|
}
|
|
|
|
var gotWindow, gotInterval time.Duration
|
|
ok := chi.NewRouter()
|
|
ok.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
|
gotWindow, gotInterval = window, interval
|
|
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
|
},
|
|
}))
|
|
req = httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity?range=48h&interval=15m", nil)
|
|
w = httptest.NewRecorder()
|
|
ok.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 at 48h/15m, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if gotWindow != 48*time.Hour || gotInterval != 15*time.Minute {
|
|
t.Errorf("expected 48h/15m, got %s/%s", gotWindow, gotInterval)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_BadRequests(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
query string
|
|
}{
|
|
{"invalid uuid", "not-a-uuid", ""},
|
|
{"unparseable range", "00000000-0000-0000-0000-000000000001", "?range=banana"},
|
|
{"range too long", "00000000-0000-0000-0000-000000000001", "?range=721h"},
|
|
{"zero range", "00000000-0000-0000-0000-000000000001", "?range=0"},
|
|
{"invalid interval", "00000000-0000-0000-0000-000000000001", "?interval=2h"},
|
|
{"too many buckets", "00000000-0000-0000-0000-000000000001", "?range=720h&interval=5m"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
|
t.Fatal("reader should not be called")
|
|
return nil, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+tt.path+"/activity"+tt.query, nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_NotFound(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
|
return nil, pgx.ErrNoRows
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", w.Code)
|
|
}
|
|
var body struct {
|
|
Error APIError `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body.Error.Code != "not_found" {
|
|
t.Errorf("expected code not_found, got %q", body.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_ReaderError(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
|
return nil, errors.New("boom")
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetObserverActivity_NullsSerialise(t *testing.T) {
|
|
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
r := chi.NewRouter()
|
|
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
|
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
|
return &api.ObserverActivity{
|
|
PayloadTypes: []api.PayloadBreakdownItem{},
|
|
Points: []api.ObserverActivityPoint{{T: 1757376000000, Observations: 3}},
|
|
}, nil
|
|
},
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/observers/"+observerID.String()+"/activity", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
radio, ok := body["radio"]
|
|
if !ok {
|
|
t.Fatal("expected radio key in body")
|
|
}
|
|
if radio != nil {
|
|
t.Errorf("expected radio null, got %v", radio)
|
|
}
|
|
points, ok := body["points"].([]any)
|
|
if !ok || len(points) != 1 {
|
|
t.Fatalf("expected 1 point, got %v", body["points"])
|
|
}
|
|
point := points[0].(map[string]any)
|
|
airtime, ok := point["airtimeMs"]
|
|
if !ok {
|
|
t.Fatal("expected airtimeMs key in point")
|
|
}
|
|
if airtime != nil {
|
|
t.Errorf("expected airtimeMs null, got %v", airtime)
|
|
}
|
|
}
|