package console import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" ) // instanceApp is a fake app serving the /.carlos/instances contract. Its // httptest host doubles as the route host (the fetcher is pointed at scheme // "http" in tests). type instanceApp struct { srv *httptest.Server hits atomic.Int64 body string code int wait time.Duration } func newInstanceApp(t *testing.T, body string) *instanceApp { t.Helper() a := &instanceApp{body: body, code: http.StatusOK} a.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != InstancesPath { http.NotFound(w, r) return } a.hits.Add(1) if a.wait > 0 { time.Sleep(a.wait) } w.WriteHeader(a.code) w.Write([]byte(a.body)) })) t.Cleanup(a.srv.Close) return a } func (a *instanceApp) host(t *testing.T) string { t.Helper() u, err := url.Parse(a.srv.URL) if err != nil { t.Fatal(err) } return u.Host } const kassReport = `[ {"slug":"riders-2026","status":"ready","region":"eu-west-1"}, {"slug":"spring-camp","status":"ready","region":"eu-west-1"}, {"slug":"trial-run","status":"archived","detail":"read-only since June"} ]` func readyRoute(host string) RouteView { return RouteView{Host: host, Kind: "instance", Status: "ready"} } func TestInstancesFetchAndCache(t *testing.T) { app := newInstanceApp(t, kassReport) f := newInstanceFetcher(nil, "http") rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if rep == nil || !rep.Reporting { t.Fatalf("report = %+v, want reporting", rep) } if len(rep.Instances) != 3 { t.Fatalf("instances = %+v, want 3", rep.Instances) } if in := rep.Instances[0]; in.Slug != "riders-2026" || in.Status != "ready" || in.Region != "eu-west-1" { t.Fatalf("first instance = %+v", in) } if in := rep.Instances[2]; in.Detail != "read-only since June" { t.Fatalf("third instance = %+v", in) } // A second report within the TTL is served from cache — one hit total. f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if n := app.hits.Load(); n != 1 { t.Fatalf("app got %d hits, want 1 (cached)", n) } } func TestInstancesNotReporting(t *testing.T) { for name, app := range map[string]*instanceApp{ "404": func() *instanceApp { a := newInstanceApp(t, "nope"); a.code = 404; return a }(), "junk": newInstanceApp(t, "not json"), "nonarray": newInstanceApp(t, `{"slug":"x"}`), "oversized": newInstanceApp(t, `[{"slug":"`+strings.Repeat("x", instancesMaxBody)+`"}]`), } { f := newInstanceFetcher(nil, "http") rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if rep == nil || rep.Reporting { t.Fatalf("%s: report = %+v, want polled but not reporting", name, rep) } } // A host that isn't even listening: same graceful silence. f := newInstanceFetcher(nil, "http") rep := f.Report(context.Background(), []RouteView{readyRoute("127.0.0.1:1")}) if rep == nil || rep.Reporting { t.Fatalf("dead host: report = %+v, want not reporting", rep) } // And the failure is cached too — no re-poll inside the TTL. app := newInstanceApp(t, "junk") f2 := newInstanceFetcher(nil, "http") f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) f2.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if n := app.hits.Load(); n != 1 { t.Fatalf("failing app got %d hits, want 1 (failure cached)", n) } } func TestInstancesSlugRequired(t *testing.T) { app := newInstanceApp(t, `[{"slug":"good"},{"status":"ready"},{"slug":" "}]`) f := newInstanceFetcher(nil, "http") rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if !rep.Reporting || len(rep.Instances) != 1 || rep.Instances[0].Slug != "good" { t.Fatalf("report = %+v, want only the slugged row", rep) } } func TestInstancesTimeoutDoesNotHang(t *testing.T) { app := newInstanceApp(t, kassReport) app.wait = 500 * time.Millisecond f := newInstanceFetcher(nil, "http") f.timeout = 50 * time.Millisecond start := time.Now() rep := f.Report(context.Background(), []RouteView{readyRoute(app.host(t))}) if rep == nil || rep.Reporting { t.Fatalf("slow app report = %+v, want not reporting", rep) } if e := time.Since(start); e > 300*time.Millisecond { t.Fatalf("report took %v — the page hung on a slow app", e) } } func TestInstancesNeverPollsHibernatableOrStatic(t *testing.T) { app := newInstanceApp(t, kassReport) f := newInstanceFetcher(nil, "http") routes := []RouteView{ {Host: app.host(t), Kind: "instance", Status: "ready", Hibernate: true}, {Host: app.host(t), Kind: "static", Status: "ready"}, {Host: app.host(t), Kind: "instance", Status: "pending"}, } if rep := f.Report(context.Background(), routes); rep != nil { t.Fatalf("report = %+v, want nil (nothing pollable)", rep) } if n := app.hits.Load(); n != 0 { t.Fatalf("app got %d hits — a hibernatable/static/unready host was polled", n) } // The first pollable route wins; later routes are left alone. other := newInstanceApp(t, `[]`) routes = append(routes, readyRoute(app.host(t)), readyRoute(other.host(t))) rep := f.Report(context.Background(), routes) if rep == nil || rep.Host != app.host(t) { t.Fatalf("report = %+v, want the first ready host", rep) } if n := other.hits.Load(); n != 0 { t.Fatalf("second host got %d hits, want 0", n) } } // TestInstancesNeverPollsSidecar: a sidecar row shares its app's routes // slice (Assemble groups by app NAME, no kind filter) but its Host is a // derived .sidecar.internal label nothing can dial — polling it wastes the // timeout and, worse, makes a HEALTHY app read "not reporting" because the // one route the picker chose was never routable to begin with. A ready, // non-hibernating, non-static sidecar must still be skipped in favor of the // next real instance route. func TestInstancesNeverPollsSidecar(t *testing.T) { sidecar := newInstanceApp(t, kassReport) real := newInstanceApp(t, kassReport) f := newInstanceFetcher(nil, "http") routes := []RouteView{ {Host: sidecar.host(t), Kind: "sidecar", Status: "ready"}, readyRoute(real.host(t)), } rep := f.Report(context.Background(), routes) if rep == nil || rep.Host != real.host(t) { t.Fatalf("report = %+v, want the real instance host — the sidecar is unroutable", rep) } if n := sidecar.hits.Load(); n != 0 { t.Fatalf("sidecar got %d hits — it must never be polled", n) } // A sidecar-only route set: nothing pollable, same as hibernating/static. f2 := newInstanceFetcher(nil, "http") if rep := f2.Report(context.Background(), []RouteView{{Host: sidecar.host(t), Kind: "sidecar", Status: "ready"}}); rep != nil { t.Fatalf("report = %+v, want nil — a sidecar-only app has nothing pollable", rep) } }