mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 16:00:03 +00:00
Record each attempt before sending so a request lost to a deadline still waits out MeshMapper's limit, and stop holding the directory lock across HTTP. Groups now qualify only through heard or configured IATAs, and one missing country list only skips pruning.
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 := NewDirectory(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")
|
|
}
|
|
}
|