Files
rmcguire 9e94696363
Publish / release (push) Successful in 1m23s
add string type enums and retry config
2026-06-23 22:35:26 -04:00

220 lines
6.0 KiB
Go

package client
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
)
// fixtures map a request path to a captured station response. The readsb files
// and the Wingbits files are reused from their respective type packages' testdata
// so there is a single source of truth for sample payloads.
var fixtures = map[string]string{
"/readsb/aircraft.json": "../types/readsb/testdata/aircraft.json",
"/data/aircraft.json": "../types/readsb/testdata/aircraft.json",
"/data/receiver.json": "../types/readsb/testdata/receiver.json",
"/data/stats.json": "../types/readsb/testdata/stats.json",
"/data/outline.json": "../types/readsb/testdata/outline.json",
"/network/diagnostics": "../types/wingbits/testdata/diagnostics.json",
"/tailscale/status": "../types/wingbits/testdata/status.json",
"/metrics": "../types/wingbits/testdata/metrics.txt",
}
// newTestClient stands up an httptest server that serves the captured station
// fixtures from both the Wingbits (readsb/) and tar1090 (data/) roots.
func newTestClient(t *testing.T) *Client {
t.Helper()
mux := http.NewServeMux()
for path, file := range fixtures {
mux.HandleFunc(path, serveFile(file))
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port, DataPath: wingbitsReadsbPath}),
WithTar1090Endpoint(Endpoint{Host: host, Port: port, DataPath: defaultDataPath}),
)
if err != nil {
t.Fatal(err)
}
return c
}
func serveFile(file string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
b, err := os.ReadFile(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(b)
}
}
func splitHostPort(t *testing.T, hp string) (string, int) {
t.Helper()
h, p, ok := strings.Cut(hp, ":")
if !ok {
t.Fatalf("bad host:port %q", hp)
}
port, err := strconv.Atoi(p)
if err != nil {
t.Fatalf("bad port %q: %v", p, err)
}
return h, port
}
func TestClientReadsbEndpoints(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
all, err := c.Aircraft(ctx)
if err != nil || len(all.Aircraft) == 0 {
t.Fatalf("aircraft: %v (%d)", err, len(all.Aircraft))
}
// Filters supplied to the query are applied before returning.
pos, err := c.Aircraft(ctx, readsb.WithPosition())
if err != nil {
t.Fatal(err)
}
if len(pos.Aircraft) == 0 || len(pos.Aircraft) > len(all.Aircraft) {
t.Errorf("filtered %d of %d", len(pos.Aircraft), len(all.Aircraft))
}
rc, err := c.Receiver(ctx)
if err != nil || !rc.HasLocation() {
t.Fatalf("receiver: %v", err)
}
if _, err := c.Stats(ctx); err != nil {
t.Fatalf("stats: %v", err)
}
o, err := c.Outline(ctx)
if err != nil || len(o.ActualRange.Last24h.Points) == 0 {
t.Fatalf("outline: %v", err)
}
}
func TestOutlineRequiresTar1090(t *testing.T) {
c, err := New(WithWingbitsEndpoint(Endpoint{Host: "127.0.0.1"}))
if err != nil {
t.Fatal(err)
}
if _, err := c.Outline(context.Background()); err == nil {
t.Error("expected error without tar1090 endpoint")
}
}
func TestClientWingbitsEndpoints(t *testing.T) {
c := newTestClient(t)
ctx := context.Background()
d, err := c.Diagnostics(ctx)
if err != nil || d.Hostname == "" {
t.Fatalf("diagnostics: %v", err)
}
if !d.AllReachable() {
t.Error("expected fixture probes all reachable")
}
s, err := c.Status(ctx)
if err != nil || s.Hostname == "" {
t.Fatalf("status: %v", err)
}
m, err := c.Metrics(ctx)
if err != nil {
t.Fatalf("metrics: %v", err)
}
wb := m.Wingbits()
if wb.Version == "" {
t.Error("expected wingbits_version label")
}
if !wb.NATS.Connected {
t.Error("expected NATS connected in fixture")
}
if wb.Beast.Received == 0 {
t.Error("expected beast received counter")
}
}
func TestClientRetry(t *testing.T) {
// Fail with 503 for the first two attempts, then succeed; the client must
// retry past the failures and return the eventual body.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if hits++; hits <= 2 {
http.Error(w, "warming up", http.StatusServiceUnavailable)
return
}
w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err != nil {
t.Fatalf("status after retries: %v", err)
}
if hits != 3 {
t.Fatalf("expected 3 attempts, got %d", hits)
}
}
func TestClientRetryGivesUp(t *testing.T) {
// A 404 is not retryable: the client must fail after a single attempt even
// with retries configured.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
http.Error(w, "nope", http.StatusNotFound)
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err == nil {
t.Fatal("expected error on 404")
}
if hits != 1 {
t.Fatalf("expected 1 attempt for non-retryable status, got %d", hits)
}
}
func TestPollAircraft(t *testing.T) {
c := newTestClient(t)
ctx := t.Context()
// A long interval means the iterator only yields its immediate first sample;
// breaking out must stop polling cleanly (no goroutine, no hang).
var got int
for report, err := range c.PollAircraft(ctx, time.Hour, readsb.WithPosition()) {
if err != nil || report == nil || len(report.Aircraft) == 0 {
t.Fatalf("first poll bad: %v", err)
}
got++
break
}
if got != 1 {
t.Fatalf("expected exactly one sample before break, got %d", got)
}
}