mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 22:30:43 +00:00
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.
237 lines
8.4 KiB
Go
237 lines
8.4 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package meshmapper
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
|
)
|
|
|
|
const groupedList = `{"country":"CA","zones":[` +
|
|
`{"code":"YOW","name":"Ottawa, CA","short_name":"Ottawa","lat":45,"lon":-76,"url":"URL/","has_boundary":true,"group":"ONQC"},` +
|
|
`{"code":"YUL","name":"Montreal, CA","lat":46,"lon":-74,"url":"URL/","has_boundary":true,"group":"ONQC"},` +
|
|
`{"code":"YQB","name":"Quebec City, CA","url":"URL/","has_boundary":true,"group":"ONQC"}],"groups":[` +
|
|
`{"code":"ONQC","name":"Ottawa-Quebec Corridor","members":["YUL","YOW","YQB"],"url":"https://onqc.meshmapper.net/"},` +
|
|
`{"code":"GOLM","name":"Great Ontario Lake Mesh","members":["YYZ","YKF"],"url":"https://golm.meshmapper.net/"}]}`
|
|
|
|
type groupHarness struct {
|
|
z *Zones
|
|
f *fakeMeshMapper
|
|
store *zoneMemoryStore
|
|
invalidated int
|
|
}
|
|
|
|
func newGroupHarness(t *testing.T, f *fakeMeshMapper, store *zoneMemoryStore, importGroups bool) *groupHarness {
|
|
t.Helper()
|
|
if store.iatas == nil {
|
|
store.iatas = []string{"YOW"}
|
|
}
|
|
if store.heard == nil {
|
|
store.heard = slices.Clone(store.iatas)
|
|
}
|
|
if store.rows == nil {
|
|
store.rows = map[string]Boundary{}
|
|
}
|
|
h := &groupHarness{f: f, store: store}
|
|
dir := newTestDirectory(newZoneListMemory())
|
|
dir.listURL = f.URL + "/get_zones.php"
|
|
h.z = NewZones(config.MeshMapperZonesConfig{Enabled: true, ImportGroups: importGroups}, store, dir)
|
|
h.z.boundsURL = func(site string) (string, bool) { return site + "get_geojson.php", true }
|
|
h.z.OnRegionsChange(func(context.Context) { h.invalidated++ })
|
|
if err := h.z.Restore(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func groupedFake(t *testing.T) *fakeMeshMapper {
|
|
f := newFakeMeshMapper(t)
|
|
f.list = strings.ReplaceAll(groupedList, "URL", f.URL)
|
|
return f
|
|
}
|
|
|
|
// refetch makes the country list and YOW due, then runs the fetch and the sync tick.
|
|
func (h *groupHarness) refetch(t *testing.T) {
|
|
t.Helper()
|
|
if list := h.z.dir.lists["CA"]; list != nil {
|
|
list.fetchedAt, list.nextAttempt = time.Time{}, time.Time{}
|
|
}
|
|
for _, r := range h.z.regions {
|
|
r.b.NextAttempt = time.Time{}
|
|
}
|
|
for range 2 {
|
|
if err := h.z.Refresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroupImportedWhenAMemberIsKnown(t *testing.T) {
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{}, true)
|
|
h.refetch(t)
|
|
got, ok := h.store.regions["onqc"]
|
|
if !ok || got.Name != "Ottawa-Quebec Corridor" || !slices.Equal(got.IATAs, []string{"YOW", "YQB", "YUL"}) || got.DisplayOrder != 1 {
|
|
t.Fatalf("group not imported with full members: %+v", h.store.regions)
|
|
}
|
|
if _, ok := h.store.regions["golm"]; ok {
|
|
t.Fatal("group with no known member imported")
|
|
}
|
|
if got.CenterLat == nil || *got.CenterLat != 45.5 || *got.CenterLng != -75 {
|
|
t.Fatalf("center should average the located members: %v, %v", got.CenterLat, got.CenterLng)
|
|
}
|
|
if !slices.Contains(h.store.iatas, "YQB") || !slices.Contains(h.store.iatas, "YUL") {
|
|
t.Fatal("members not created as IATAs", h.store.iatas)
|
|
}
|
|
if h.invalidated != 1 {
|
|
t.Fatal("region caches not invalidated", h.invalidated)
|
|
}
|
|
h.refetch(t)
|
|
if h.invalidated != 1 {
|
|
t.Fatal("unchanged groups invalidated again", h.invalidated)
|
|
}
|
|
}
|
|
|
|
func TestGroupSlugClashKeepsConfiguredRegion(t *testing.T) {
|
|
hand := RegionState{Slug: "onqc", Name: "Mine", DisplayOrder: 4, IATAs: []string{"YOW"}}
|
|
store := &zoneMemoryStore{regions: map[string]RegionState{"onqc": hand}}
|
|
h := newGroupHarness(t, groupedFake(t), store, true)
|
|
h.refetch(t)
|
|
if got := store.regions["onqc"]; got.Imported || got.Name != "Mine" || !slices.Equal(got.IATAs, []string{"YOW"}) || h.invalidated != 0 {
|
|
t.Fatalf("configured region replaced: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGroupDroppedRemovesRegion(t *testing.T) {
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{}, true)
|
|
h.refetch(t)
|
|
h.f.list = strings.ReplaceAll(`{"country":"CA","zones":[{"code":"YOW","url":"URL/","has_boundary":true,"group":null}],"groups":[]}`, "URL", h.f.URL)
|
|
h.refetch(t)
|
|
if len(h.store.regions) != 0 || h.invalidated != 2 {
|
|
t.Fatalf("dropped group kept: %+v invalidated=%d", h.store.regions, h.invalidated)
|
|
}
|
|
}
|
|
|
|
func TestGroupImportOffRemovesImportedRegions(t *testing.T) {
|
|
hand := RegionState{Slug: "east", Name: "East", IATAs: []string{"YOW"}}
|
|
imported := RegionState{Slug: "onqc", Name: "Ottawa-Quebec Corridor", Imported: true, IATAs: []string{"YOW"}}
|
|
store := &zoneMemoryStore{regions: map[string]RegionState{"east": hand, "onqc": imported}}
|
|
h := newGroupHarness(t, groupedFake(t), store, false)
|
|
if _, ok := store.regions["onqc"]; ok || h.invalidated != 1 {
|
|
t.Fatal("imported region kept with import_groups off")
|
|
}
|
|
if _, ok := store.regions["east"]; !ok {
|
|
t.Fatal("configured region removed")
|
|
}
|
|
h.refetch(t)
|
|
if len(store.regions) != 1 {
|
|
t.Fatal("groups imported with import_groups off", store.regions)
|
|
}
|
|
}
|
|
|
|
func TestGroupFailedFetchKeepsGroups(t *testing.T) {
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{}, true)
|
|
h.refetch(t)
|
|
h.f.listStatus = 500
|
|
h.z.groupsSynced = false // force a reconcile against the retained list
|
|
h.refetch(t)
|
|
if _, ok := h.store.regions["onqc"]; !ok {
|
|
t.Fatal("failed fetch dropped imported region")
|
|
}
|
|
}
|
|
|
|
func TestGroupColdStartWaitsForEveryList(t *testing.T) {
|
|
old := RegionState{Slug: "old", Name: "Old", Imported: true, IATAs: []string{"YOW"}}
|
|
store := &zoneMemoryStore{regions: map[string]RegionState{"old": old}}
|
|
f := groupedFake(t)
|
|
f.listStatus = 500
|
|
h := newGroupHarness(t, f, store, true)
|
|
h.refetch(t)
|
|
if _, ok := store.regions["old"]; !ok {
|
|
t.Fatal("imported regions pruned before any list loaded")
|
|
}
|
|
}
|
|
|
|
func TestZoneListNamesAndLocatesIATAs(t *testing.T) {
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{iatas: []string{"YOW", "YUL", "YQB", "YYZ"}}, false)
|
|
h.z.SetConfiguredIATAs([]string{"YUL"})
|
|
changed := 0
|
|
h.z.OnIATAsChange(func(context.Context) { changed++ })
|
|
h.refetch(t)
|
|
yow := h.store.details["YOW"]
|
|
if yow.Name != "Ottawa" || yow.Lat == nil || *yow.Lat != 45 || *yow.Lng != -76 {
|
|
t.Fatalf("YOW not named and located from short_name: %+v", yow)
|
|
}
|
|
if yqb := h.store.details["YQB"]; yqb.Name != "Quebec City, CA" || yqb.Lat != nil {
|
|
t.Fatalf("YQB should fall back to name and stay unlocated: %+v", yqb)
|
|
}
|
|
if _, ok := h.store.details["YUL"]; ok {
|
|
t.Fatal("configured IATA overwritten")
|
|
}
|
|
if _, ok := h.store.details["YYZ"]; ok {
|
|
t.Fatal("unlisted IATA written")
|
|
}
|
|
if changed != 1 || h.store.writes != 2 {
|
|
t.Fatal("writes/invalidations", h.store.writes, changed)
|
|
}
|
|
h.z.groupsSynced = false
|
|
h.refetch(t)
|
|
if h.store.writes != 2 || changed != 1 {
|
|
t.Fatal("unchanged details rewritten", h.store.writes, changed)
|
|
}
|
|
}
|
|
|
|
func TestGroupStopsQualifyingOnImportedMembers(t *testing.T) {
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{}, true)
|
|
h.refetch(t)
|
|
if _, ok := h.store.regions["onqc"]; !ok {
|
|
t.Fatal("group not imported")
|
|
}
|
|
without := strings.Replace(groupedList, `"members":["YUL","YOW","YQB"]`, `"members":["YUL","YQB"]`, 1)
|
|
h.f.list = strings.ReplaceAll(without, "URL", h.f.URL)
|
|
h.refetch(t)
|
|
if _, ok := h.store.regions["onqc"]; ok {
|
|
t.Fatal("group kept only by members its own import created")
|
|
}
|
|
}
|
|
|
|
func TestGroupQualifiesThroughConfiguredRegion(t *testing.T) {
|
|
hand := RegionState{Slug: "east", Name: "East", IATAs: []string{"YYZ"}}
|
|
h := newGroupHarness(t, groupedFake(t), &zoneMemoryStore{regions: map[string]RegionState{"east": hand}}, true)
|
|
h.refetch(t)
|
|
if _, ok := h.store.regions["golm"]; !ok {
|
|
t.Fatal("group with a configured member not imported")
|
|
}
|
|
}
|
|
|
|
func TestGroupImportsFromListsItHas(t *testing.T) {
|
|
old := RegionState{Slug: "old", Name: "Old", Imported: true, IATAs: []string{"YOW"}}
|
|
store := &zoneMemoryStore{iatas: []string{"YOW", "SEA"}, regions: map[string]RegionState{"old": old}}
|
|
f := groupedFake(t)
|
|
f.otherStatus = 500
|
|
h := newGroupHarness(t, f, store, true)
|
|
h.refetch(t)
|
|
for range 2 {
|
|
if err := h.z.Refresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if f.otherStatus != 500 || h.z.dir.lists["US"] == nil || h.z.dir.lists["US"].zones != nil {
|
|
t.Fatal("US list should have failed")
|
|
}
|
|
if _, ok := store.regions["onqc"]; !ok {
|
|
t.Fatal("one failed country blocked group import")
|
|
}
|
|
if store.details["YOW"].Name != "Ottawa" {
|
|
t.Fatal("one failed country blocked IATA details", store.details)
|
|
}
|
|
if _, ok := store.regions["old"]; !ok {
|
|
t.Fatal("pruned with a country list missing")
|
|
}
|
|
}
|