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) } }