mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-17 22:20:12 +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.
487 lines
17 KiB
Go
487 lines
17 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package meshmapper
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
|
|
)
|
|
|
|
func catalogue(names ...string) []byte {
|
|
rows := make([]map[string]any, 0, len(names))
|
|
for _, name := range names {
|
|
rows = append(rows, map[string]any{"name": name, "repeaters": 0, "default": 0, "monitored": true, "wardriving": false})
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"generated_at": "2026-09-27T16:58:49Z", "region": "YOW", "zones": []string{"YOW"}, "repeaters": 5, "scoped": 3, "scopes": rows})
|
|
return b
|
|
}
|
|
|
|
type sourceKey struct{ iata, url string }
|
|
|
|
type memoryStore struct {
|
|
iatas []string
|
|
rows map[sourceKey]Cache
|
|
fail bool
|
|
saved []scopestore.Entry
|
|
}
|
|
|
|
func newMemoryStore(iatas ...string) *memoryStore {
|
|
return &memoryStore{iatas: iatas, rows: map[sourceKey]Cache{}}
|
|
}
|
|
|
|
func (s *memoryStore) ListKnownIATAs(context.Context) ([]string, error) {
|
|
return slices.Clone(s.iatas), nil
|
|
}
|
|
|
|
func (s *memoryStore) ListScopeCatalogues(context.Context) ([]Catalogue, error) {
|
|
var out []Catalogue
|
|
for key, c := range s.rows {
|
|
out = append(out, Catalogue{IATA: key.iata, URL: key.url, Cache: c})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *memoryStore) SaveScopeCatalogue(_ context.Context, iata, url string, next Cache, entries []scopestore.Entry) error {
|
|
if s.fail {
|
|
return errors.New("offline")
|
|
}
|
|
old := s.rows[sourceKey{iata, url}]
|
|
if next.Payload == nil {
|
|
next.Payload = old.Payload
|
|
}
|
|
if next.CheckedAt.IsZero() {
|
|
next.CheckedAt = old.CheckedAt
|
|
next.ETag = old.ETag
|
|
}
|
|
s.rows[sourceKey{iata, url}] = next
|
|
s.saved = append(s.saved, entries...)
|
|
return nil
|
|
}
|
|
|
|
// newImporter restores an importer for YOW whose source points at url.
|
|
func newImporter(t *testing.T, cfg config.MeshMapperScopesConfig, store *memoryStore, url string, scopes *scopestore.ScopeStore, manual ...scopestore.Entry) *Importer {
|
|
t.Helper()
|
|
imp, err := New(context.Background(), cfg, store, newTestDirectory(newZoneListMemory()), scopes, manual)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(imp.sources) != 1 {
|
|
t.Fatal("expected one source", imp.sources)
|
|
}
|
|
imp.sources[0].url = url
|
|
return imp
|
|
}
|
|
|
|
func names(store *scopestore.ScopeStore) []string {
|
|
out := []string{}
|
|
for _, e := range store.Entries() {
|
|
out = append(out, e.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestCatalogueRefreshFallbackAndRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
status, body, etag, after := 200, catalogue("yow", "manual"), `"v1"`, ""
|
|
calls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if calls > 1 && r.Header.Get("If-None-Match") != `"v1"` {
|
|
t.Errorf("missing conditional refresh: %v", r.Header)
|
|
}
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Retry-After", after)
|
|
w.WriteHeader(status)
|
|
if status != 304 {
|
|
_, _ = w.Write(body)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
cfg := config.MeshMapperScopesConfig{Enabled: true}
|
|
store := newMemoryStore("YOW")
|
|
scopes := scopestore.New()
|
|
manual := scopestore.FromName("manual")
|
|
manual.TransportKey = []byte("manual wins")
|
|
imp := newImporter(t, cfg, store, server.URL, scopes, manual)
|
|
now := time.Now().UTC()
|
|
check := func() {
|
|
t.Helper()
|
|
now = now.Add(time.Minute)
|
|
if err := imp.refresh(ctx, &imp.sources[0], now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
check()
|
|
if got := names(scopes); !reflect.DeepEqual(got, []string{"#manual", "#yow"}) {
|
|
t.Fatal(got)
|
|
}
|
|
if string(scopes.Entries()[0].TransportKey) != "manual wins" || scopes.Entries()[0].IATAs != nil {
|
|
t.Fatal("manual key replaced/restricted")
|
|
}
|
|
if !reflect.DeepEqual(scopes.Entries()[1].IATAs, []string{"YOW"}) {
|
|
t.Fatal("import not bound to region")
|
|
}
|
|
if got := scopes.NamesForIATAs([]string{"YOW"}); !reflect.DeepEqual(got, []string{"#manual", "#yow"}) {
|
|
t.Fatal("catalogue membership must include manually overridden names", got)
|
|
}
|
|
generated := imp.sources[0].generated
|
|
status = 304
|
|
check()
|
|
checked := imp.sources[0].cache.CheckedAt
|
|
if checked != now || imp.sources[0].generated != generated {
|
|
t.Fatal("304 lost freshness or changed generation time")
|
|
}
|
|
for _, failure := range []int{200, 404, 503, 302} {
|
|
status, body = failure, []byte(`{"scopes":null}`)
|
|
check()
|
|
if imp.sources[0].cache.CheckedAt != checked || len(scopes.Entries()) != 2 {
|
|
t.Fatal("failed refresh replaced known catalogue")
|
|
}
|
|
}
|
|
status, after = 429, "7200"
|
|
check()
|
|
if !imp.sources[0].cache.NextAttempt.Equal(now.Add(2 * time.Hour)) {
|
|
t.Fatal("Retry-After ignored")
|
|
}
|
|
restored := scopestore.New()
|
|
restart := newImporter(t, cfg, store, server.URL, restored, manual)
|
|
if !reflect.DeepEqual(names(restored), names(scopes)) {
|
|
t.Fatal("outage restart lost cached names")
|
|
}
|
|
before := calls
|
|
if err := restart.Refresh(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != before {
|
|
t.Fatal("restart retried before Retry-After")
|
|
}
|
|
status, after, body = 200, "", catalogue()
|
|
check()
|
|
if got := names(scopes); !reflect.DeepEqual(got, []string{"#manual"}) {
|
|
t.Fatalf("empty snapshot: %v", got)
|
|
}
|
|
store.fail = true
|
|
body = catalogue("new")
|
|
now = now.Add(time.Hour)
|
|
if err := imp.refresh(ctx, &imp.sources[0], now); err == nil || len(scopes.Entries()) != 1 {
|
|
t.Fatal("uncommitted names became active")
|
|
}
|
|
}
|
|
|
|
func TestDecodeCatalogueBoundaries(t *testing.T) {
|
|
valid := string(catalogue("yow", "YOW", "$private"))
|
|
bad := []string{
|
|
`null`, `{}`, `[]`, valid + `{}`,
|
|
strings.Replace(valid, `"region":"YOW"`, `"region":"PNW"`, 1),
|
|
strings.Replace(valid, `"zones":["YOW"]`, `"zones":["YOW","YVR"]`, 1),
|
|
strings.Replace(valid, `"scoped":3`, `"scoped":6`, 1),
|
|
strings.Replace(valid, `"repeaters":0`, `"repeaters":-1`, 1),
|
|
strings.Replace(valid, `"monitored":true,`, ``, 1),
|
|
strings.Replace(valid, `"generated_at":"2026-09-27T16:58:49Z"`, `"generated_at":null`, 1),
|
|
string(catalogue("yow", "#yow")), string(catalogue("#")), string(catalogue(" yow")), string(catalogue("a\nb")),
|
|
strings.Repeat(" ", MaxBody+1),
|
|
}
|
|
tooMany := []string{}
|
|
for n := 0; n <= MaxScopes; n++ {
|
|
tooMany = append(tooMany, fmt.Sprint(n))
|
|
}
|
|
bad = append(bad, string(catalogue(tooMany...)))
|
|
for n, raw := range bad {
|
|
if _, _, err := decode([]byte(raw), "YOW"); err == nil {
|
|
t.Errorf("invalid case %d accepted", n)
|
|
}
|
|
}
|
|
entries, _, err := decode([]byte(valid), "YOW")
|
|
if err != nil || len(entries) != 3 {
|
|
t.Fatal(entries, err)
|
|
}
|
|
if entries[0].Name != "#yow" || entries[1].Name != "#YOW" || entries[2].Name != "$private" {
|
|
t.Fatal("case/prefix changed", entries)
|
|
}
|
|
}
|
|
|
|
func TestNoRedirectAndResponseBounds(t *testing.T) {
|
|
targetCalls := 0
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetCalls++ }))
|
|
defer target.Close()
|
|
var mode atomic.Value
|
|
mode.Store("redirect")
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch mode.Load().(string) {
|
|
case "redirect":
|
|
http.Redirect(w, r, target.URL, 302)
|
|
case "oversize":
|
|
_, _ = w.Write([]byte(strings.Repeat("x", MaxBody+1)))
|
|
case "timeout":
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-time.After(time.Second):
|
|
}
|
|
case "304":
|
|
w.WriteHeader(304)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
imp := newImporter(t, config.MeshMapperScopesConfig{Enabled: true}, newMemoryStore("YOW"), server.URL, scopestore.New())
|
|
imp.client.Timeout = 20 * time.Millisecond
|
|
for _, value := range []string{"redirect", "oversize", "timeout", "304"} {
|
|
mode.Store(value)
|
|
if err := imp.refresh(context.Background(), &imp.sources[0], time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if imp.sources[0].cache.LastError == "" || len(imp.sources[0].entries) != 0 {
|
|
t.Fatal("invalid response accepted", value)
|
|
}
|
|
}
|
|
if targetCalls != 0 {
|
|
t.Fatal("followed redirect")
|
|
}
|
|
}
|
|
|
|
func TestImportedSourcesOverlapWithoutGlobalMembership(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newMemoryStore("YOW", "YVR")
|
|
for _, iata := range []string{"YOW", "YVR"} {
|
|
store.rows[sourceKey{iata, "old-" + iata}] = Cache{Payload: []byte(`{}`)}
|
|
store.rows[sourceKey{iata, iata}] = Cache{Payload: []byte(strings.ReplaceAll(string(catalogue("can")), "YOW", iata)), AttemptedAt: time.Now()}
|
|
}
|
|
scopes := scopestore.New()
|
|
_, err := New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, newTestDirectory(newZoneListMemory()), scopes, nil)
|
|
if err != nil || len(scopes.Entries()) != 1 || !reflect.DeepEqual(scopes.Entries()[0].IATAs, []string{"YOW", "YVR"}) {
|
|
t.Fatal("latest saved catalogue per IATA not restored", scopes.Entries(), err)
|
|
}
|
|
if got := scopes.NamesForIATAs([]string{"YVR"}); !reflect.DeepEqual(got, []string{"#can"}) {
|
|
t.Fatal(got)
|
|
}
|
|
// Disabled mode never calls the store.
|
|
disabled := scopestore.New()
|
|
if _, err = New(ctx, config.MeshMapperScopesConfig{}, nil, newTestDirectory(newZoneListMemory()), disabled, nil); err != nil || len(disabled.Entries()) != 0 {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestInvalidSavedCatalogueDoesNotBlockStartupOrRefresh(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("If-None-Match") != "" {
|
|
t.Error("invalid saved catalogue must not accept a 304")
|
|
}
|
|
_, _ = w.Write(catalogue("yow"))
|
|
}))
|
|
defer server.Close()
|
|
store := newMemoryStore("YOW")
|
|
store.rows[sourceKey{"YOW", server.URL}] = Cache{Payload: []byte(`{"invalid":true}`), ETag: `"old"`}
|
|
scopes := scopestore.New()
|
|
imp := newImporter(t, config.MeshMapperScopesConfig{Enabled: true}, store, server.URL, scopes, scopestore.FromName("manual"))
|
|
if !reflect.DeepEqual(names(scopes), []string{"#manual"}) {
|
|
t.Fatal("invalid optional source blocked manual startup")
|
|
}
|
|
if err := imp.refresh(context.Background(), &imp.sources[0], time.Now()); err != nil || !reflect.DeepEqual(names(scopes), []string{"#manual", "#yow"}) {
|
|
t.Fatal("source did not recover on refresh", err, names(scopes))
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
imp.sources[0].cache.NextAttempt = time.Time{}
|
|
if err := imp.Refresh(ctx); err != nil {
|
|
t.Fatalf("normal shutdown is a task failure: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterNeverBeatsTheRateLimit(t *testing.T) {
|
|
for retryAfter, want := range map[string]time.Duration{"1800": time.Hour, "7200": 2 * time.Hour} {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Retry-After", retryAfter)
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
}))
|
|
imp := newImporter(t, config.MeshMapperScopesConfig{Enabled: true}, newMemoryStore("YOW"), server.URL, scopestore.New())
|
|
now := time.Now().UTC()
|
|
if err := imp.refresh(context.Background(), &imp.sources[0], now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server.Close()
|
|
if got := imp.sources[0].cache.NextAttempt.Sub(now); got != want {
|
|
t.Fatalf("Retry-After %ss: next attempt in %v, want %v", retryAfter, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFailedRefreshRetriesSoon(t *testing.T) {
|
|
ctx := context.Background()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(500) }))
|
|
defer server.Close()
|
|
imp := newImporter(t, config.MeshMapperScopesConfig{Enabled: true}, newMemoryStore("YOW"), server.URL, scopestore.New())
|
|
now := time.Now().UTC()
|
|
if err := imp.refresh(ctx, &imp.sources[0], now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := imp.sources[0].cache.NextAttempt.Sub(now); got != time.Hour {
|
|
t.Fatalf("next attempt after failure in %v, want 1h", got)
|
|
}
|
|
}
|
|
|
|
// fakeSites serves a CA zone list and YOW's scope catalogue.
|
|
type fakeSites struct {
|
|
*httptest.Server
|
|
list string
|
|
listCalls, scopeHits int
|
|
}
|
|
|
|
func newFakeSites(t *testing.T) *fakeSites {
|
|
f := &fakeSites{}
|
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/get_zones.php":
|
|
f.listCalls++
|
|
_, _ = w.Write([]byte(f.list))
|
|
case "/get_scopes.php":
|
|
f.scopeHits++
|
|
_, _ = w.Write(catalogue("yow"))
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
f.list = `{"country":"CA","zones":[{"code":"YOW","url":"` + f.URL + `/","has_boundary":true,"group":null}]}`
|
|
t.Cleanup(f.Close)
|
|
return f
|
|
}
|
|
|
|
func newDiscoveringImporter(t *testing.T, f *fakeSites, store *memoryStore, scopes *scopestore.ScopeStore) *Importer {
|
|
t.Helper()
|
|
dir := newTestDirectory(newZoneListMemory())
|
|
dir.listURL = f.URL + "/get_zones.php"
|
|
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopes, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
imp.scopesURL = func(site string) (string, bool) { return site + "get_scopes.php", strings.HasPrefix(site, f.URL) }
|
|
return imp
|
|
}
|
|
|
|
func refreshN(t *testing.T, imp *Importer, n int) {
|
|
t.Helper()
|
|
for range n {
|
|
if err := imp.Refresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScopesDiscoverSourcesFromZoneList(t *testing.T) {
|
|
f := newFakeSites(t)
|
|
store := newMemoryStore()
|
|
scopes := scopestore.New()
|
|
imp := newDiscoveringImporter(t, f, store, scopes)
|
|
refreshN(t, imp, 2)
|
|
if f.listCalls != 0 || f.scopeHits != 0 {
|
|
t.Fatal("requested with no IATAs", f.listCalls, f.scopeHits)
|
|
}
|
|
store.iatas = []string{"YOW", "ZZZ"}
|
|
refreshN(t, imp, 3)
|
|
if f.listCalls != 1 || f.scopeHits != 1 || !reflect.DeepEqual(names(scopes), []string{"#yow"}) {
|
|
t.Fatal("source not discovered", f.listCalls, f.scopeHits, names(scopes))
|
|
}
|
|
if _, ok := store.rows[sourceKey{"YOW", f.URL + "/get_scopes.php"}]; !ok {
|
|
t.Fatal("catalogue not saved under the discovered URL", store.rows)
|
|
}
|
|
}
|
|
|
|
func TestScopesSkipUnlistedIATAs(t *testing.T) {
|
|
f := newFakeSites(t)
|
|
f.list = `{"country":"CA","zones":[]}`
|
|
imp := newDiscoveringImporter(t, f, newMemoryStore("YOW"), scopestore.New())
|
|
refreshN(t, imp, 3)
|
|
s := imp.sources[0]
|
|
if f.scopeHits != 0 || s.cache.LastError != "not listed" || s.cache.NextAttempt.Sub(time.Now()) < 59*time.Minute {
|
|
t.Fatalf("unlisted IATA requested or retried early: hits=%d %+v", f.scopeHits, s.cache)
|
|
}
|
|
}
|
|
|
|
func TestScopesRequestCutShortStillCounts(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
|
defer server.Close()
|
|
store := newMemoryStore("YOW")
|
|
imp := newImporter(t, config.MeshMapperScopesConfig{Enabled: true}, store, server.URL, scopestore.New())
|
|
now := time.Now().UTC()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
_ = imp.refresh(ctx, &imp.sources[0], now)
|
|
if got := imp.sources[0].cache.NextAttempt.Sub(now); got != failureRetry {
|
|
t.Fatal("abandoned request retried inside the rate limit", got)
|
|
}
|
|
if row := store.rows[sourceKey{"YOW", server.URL}]; row.NextAttempt.Sub(now) != failureRetry {
|
|
t.Fatal("abandoned request not persisted", row)
|
|
}
|
|
}
|
|
|
|
// newSweep serves a CA zone list whose IATAs each have a catalogue; fail lists IATAs answering 500.
|
|
func newSweep(t *testing.T, store *memoryStore, fail ...string) (*Importer, *[]string) {
|
|
t.Helper()
|
|
var hits []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/get_zones.php" {
|
|
var zones []string
|
|
for _, iata := range store.iatas {
|
|
zones = append(zones, fmt.Sprintf(`{"code":%q,"url":"%s/%s/","has_boundary":true,"group":null}`, iata, "http://"+r.Host, iata))
|
|
}
|
|
_, _ = fmt.Fprintf(w, `{"country":"CA","zones":[%s]}`, strings.Join(zones, ","))
|
|
return
|
|
}
|
|
iata := strings.Split(r.URL.Path, "/")[1]
|
|
hits = append(hits, iata)
|
|
if slices.Contains(fail, iata) {
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(strings.ReplaceAll(string(catalogue("x")), "YOW", iata)))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
dir := newTestDirectory(newZoneListMemory())
|
|
dir.listURL = server.URL + "/get_zones.php"
|
|
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopestore.New(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
imp.scopesURL = func(site string) (string, bool) { return site + "get_scopes.php", true }
|
|
refreshN(t, imp, 1) // zone list fetch
|
|
return imp, &hits
|
|
}
|
|
|
|
func TestScopesLongestWaitingGoesFirst(t *testing.T) {
|
|
store := newMemoryStore("YOW", "YUL", "YVR", "YYZ")
|
|
now := time.Now()
|
|
// Restored mid-pass: YOW/YUL done, YVR never reached; YOW is already due again.
|
|
store.rows[sourceKey{"YOW", "old"}] = Cache{AttemptedAt: now.Add(-time.Hour), NextAttempt: now.Add(-time.Minute)}
|
|
store.rows[sourceKey{"YUL", "old"}] = Cache{AttemptedAt: now, NextAttempt: now.Add(time.Hour)}
|
|
store.rows[sourceKey{"YVR", "old"}] = Cache{AttemptedAt: now.Add(-48 * time.Hour), NextAttempt: now.Add(-47 * time.Hour)}
|
|
imp, hits := newSweep(t, store)
|
|
refreshN(t, imp, 3)
|
|
if want := []string{"YYZ", "YVR", "YOW"}; !reflect.DeepEqual(*hits, want) {
|
|
t.Fatalf("refresh order %v, want %v", *hits, want)
|
|
}
|
|
}
|
|
|
|
func TestScopesFailureDoesNotStallThePass(t *testing.T) {
|
|
store := newMemoryStore("YOW", "YUL", "YVR")
|
|
imp, hits := newSweep(t, store, "YOW")
|
|
refreshN(t, imp, 4)
|
|
if want := []string{"YOW", "YUL", "YVR"}; !reflect.DeepEqual(*hits, want) {
|
|
t.Fatalf("refresh order %v, want %v", *hits, want)
|
|
}
|
|
if s := imp.sources[0]; s.iata != "YOW" || time.Until(s.cache.NextAttempt) > failureRetry {
|
|
t.Fatalf("failed source not retried early: %+v", s.cache)
|
|
}
|
|
}
|