Files
beacon-server/internal/api/handlers/observer_directory_test.go
MrAlders0n 041d9c1f45 release: v2.0.2
Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger.

MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon.

Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
2026-10-05 18:35:41 -04:00

166 lines
6.5 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func TestDirectoryRejectsInvalidPagination(t *testing.T) {
for _, q := range []string{"sort=bogus", "cursor=1", "cursor=-1", "limit=0", "snapshot=bad", "until=no", "since=0", "unknown=1", "snapshot=00000000-0000-0000-0000-000000000001&sort=name", "status=maybe", "sort=name&sort=traffic", "cursor=2147483448", "since=1767229200000&until=1767225600000", "since=1764543600000&until=1767229200000", "until=9223372036854775807"} {
t.Run(q, func(t *testing.T) {
r := stubReader{listObserverDirectory: func(context.Context, api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
t.Fatal("invalid query reached store")
return nil, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+q, nil))
if w.Code != 400 {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
})
}
}
func TestDirectoryContinuationAndErrors(t *testing.T) {
for _, tc := range []struct {
err error
code int
}{{nil, 200}, {errors.New("database unavailable"), 500}} {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if q.Sort != "name" || q.Name != "alpha" || q.Since != 1767225600123 || q.Until != 1767229200789 || q.Cursor != 50 || q.Limit != 25 {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, tc.err
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?sort=name&name=alpha&since=1767225600123&until=1767229200789&cursor=50&limit=25", nil))
if w.Code != tc.code {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
}
func TestDirectoryNormalizesFirstPage(t *testing.T) {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if q.Sort != "traffic" || q.Limit != 200 || q.Name != "alpha" || q.Since != 1767225600999 || q.Until != 1767229200999 || len(q.IATAs) != 2 || q.IATAs[0] != "YVR" || q.IATAs[1] != "YYJ" {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?iatas=yyj,yvr,YVR&name=ALPHA&since=1767225600999&until=1767229200999&limit=201", nil))
if w.Code != 200 || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response %d %s", w.Code, w.Body.String())
}
}
func TestDirectoryRollingWeekAndContinuation(t *testing.T) {
var queries []api.ObserverDirectoryQuery
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
queries = append(queries, q)
return &api.ObserverDirectory{WindowStart: q.Since, WindowEnd: q.Until}, nil
}}
router := ObserversRouter(r)
before := time.Now().UnixMilli()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", "/directory?iatas=YVR,YYJ&limit=1", nil))
after := time.Now().UnixMilli()
if w.Code != 200 || len(queries) != 1 {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
q := queries[0]
if q.Until < before || q.Until > after || q.Until-q.Since != (7*24*time.Hour).Milliseconds() {
t.Fatalf("window [%d,%d) does not span seven days through the request time", q.Since, q.Until)
}
var page api.ObserverDirectory
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", fmt.Sprintf("/directory?iatas=YVR,YYJ&limit=1&cursor=1&since=%d&until=%d", page.WindowStart, page.WindowEnd), nil))
if w.Code != 200 || len(queries) != 2 {
t.Fatalf("continuation %d: %s", w.Code, w.Body.String())
}
if next := queries[1]; next.Since != q.Since || next.Until != q.Until || next.Sort != "traffic" || !slices.Equal(next.IATAs, q.IATAs) {
t.Fatalf("continuation changed the window or filters: %+v", next)
}
}
func TestDirectoryExactWindowValidation(t *testing.T) {
const until int64 = 1767229200123
for _, tc := range []struct {
name, query string
code int
since int64
}{
{"until only defaults to seven days", fmt.Sprintf("until=%d", until), 200, until - 604800000},
{"millisecond window", fmt.Sprintf("since=%d&until=%d", until-1, until), 200, until - 1},
{"31 days", fmt.Sprintf("since=%d&until=%d", until-2678400000, until), 200, until - 2678400000},
{"over 31 days", fmt.Sprintf("since=%d&until=%d", until-2678400001, until), 400, 0},
{"empty window", fmt.Sprintf("since=%d&until=%d", until, until), 400, 0},
{"repeated bound", fmt.Sprintf("since=1&since=2&until=%d", until), 400, 0},
} {
t.Run(tc.name, func(t *testing.T) {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if tc.code != 200 || q.Since != tc.since || q.Until != until {
t.Fatalf("unexpected query: %+v", q)
}
return &api.ObserverDirectory{}, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+tc.query, nil))
if w.Code != tc.code {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
})
}
}
func TestDirectoryRegionFilters(t *testing.T) {
for _, tc := range []struct {
query string
iatas []string
none bool
}{
{"region=west", []string{"YVR", "YYJ"}, false},
{"regionId=7&iata=YOW", []string{"YOW", "YVR", "YYJ"}, false},
{"region=empty", nil, true},
} {
t.Run(tc.query, func(t *testing.T) {
r := stubReader{
getRegion: func(context.Context, int32) (*api.Region, error) {
return &api.Region{IATAs: []string{"YYJ", "YVR"}}, nil
},
getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
if slug == "empty" {
return &api.Region{}, nil
}
return &api.Region{IATAs: []string{"YYJ", "YVR"}}, nil
},
listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if !slices.Equal(q.IATAs, tc.iatas) || q.MatchNone != tc.none {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, nil
},
}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+tc.query, nil))
if w.Code != 200 {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
})
}
}