package web import ( "context" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/gitea" ) // artifactChoices fans the per-version sha lookups out concurrently (hub v0.100.0). The // Configuration page took 26 SECONDS with two dropdowns because it made 42 sequential metadata // round-trips. These tests pin the three things that must survive the change: the ORDER, the // per-version failure isolation, and the concurrency itself. // fakeGitea serves the two endpoints the client uses, with a controllable per-request delay so // "concurrent" is measurable rather than asserted. type fakeGitea struct { versions []string delay time.Duration failVer string // this version's /files call 500s inFlight int32 maxSeen int32 callCount int32 } func (f *fakeGitea) start(t *testing.T) *gitea.Client { t.Helper() mux := http.NewServeMux() // BOTH patterns: the version list is GET /api/v1/packages/admin?... with NO trailing slash, // the per-version files call is /api/v1/packages/admin/generic///files. handler := func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt32(&f.inFlight, 1) for { old := atomic.LoadInt32(&f.maxSeen) if n <= old || atomic.CompareAndSwapInt32(&f.maxSeen, old, n) { break } } defer atomic.AddInt32(&f.inFlight, -1) atomic.AddInt32(&f.callCount, 1) time.Sleep(f.delay) // .../generic///files → the sha metadata if len(r.URL.Path) > 6 && r.URL.Path[len(r.URL.Path)-6:] == "/files" { for _, v := range f.versions { if f.failVer != "" && v == f.failVer && strings.Contains(r.URL.Path, "/"+v+"/") { http.Error(w, "boom", http.StatusInternalServerError) return } } ver := versionFromFilesPath(r.URL.Path) fmt.Fprintf(w, `[{"name":"felhom-agent","sha256":"sha-%s"}]`, ver) return } // the version list out := "[" for i, v := range f.versions { if i > 0 { out += "," } out += fmt.Sprintf(`{"name":"felhom-agent","type":"generic","version":%q}`, v) } fmt.Fprint(w, out+"]") } mux.HandleFunc("/api/v1/packages/admin", handler) mux.HandleFunc("/api/v1/packages/admin/", handler) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return gitea.New(srv.URL, "admin", "u", "t") } func versionFromFilesPath(p string) string { // .../generic///files end := len(p) - len("/files") start := end - 1 for start > 0 && p[start-1] != '/' { start-- } return p[start:end] } func newTestWebServer(t *testing.T, c *gitea.Client) *Server { t.Helper() s := &Server{logger: log.New(io.Discard, "", 0)} s.SetGiteaClient(c) return s } // The dropdown is newest-first and the concurrent version must not scramble it. func TestArtifactChoices_PreservesOrder(t *testing.T) { f := &fakeGitea{versions: []string{"0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0"}} s := newTestWebServer(t, f.start(t)) got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") if len(got) != 5 { t.Fatalf("got %d choices, want 5: %+v", len(got), got) } // ListVersions sorts newest-first; the fan-out must not disturb that, and each sha must belong // to ITS OWN version — a slot mix-up would show the operator a hash for a different artifact. for i, want := range []string{"0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0"} { if got[i].Version != want { t.Errorf("choice %d = %q, want %q — the concurrent fan-out scrambled the order", i, got[i].Version, want) } if got[i].SHA256 != "sha-"+want { t.Errorf("version %s carries sha %q — a sha was written into the wrong slot, which would show "+ "the operator a hash belonging to a different artifact", got[i].Version, got[i].SHA256) } } } // A single failing version drops ITSELF and nothing else — unchanged from the serial version. func TestArtifactChoices_OneFailureDropsOnlyThatVersion(t *testing.T) { f := &fakeGitea{versions: []string{"0.9.0", "0.8.0", "0.7.0"}, failVer: "0.8.0"} s := newTestWebServer(t, f.start(t)) got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") if len(got) != 2 { t.Fatalf("got %d choices, want 2 (the failing one dropped): %+v", len(got), got) } for _, c := range got { if c.Version == "0.8.0" { t.Error("the failing version was included") } } if got[0].Version != "0.9.0" || got[1].Version != "0.7.0" { t.Errorf("order broken around the dropped version: %+v", got) } } // THE POINT OF THE CHANGE. With a per-request delay, a serial implementation takes // len(versions) * delay; the concurrent one takes about ceil(n/8) * delay. Asserting the wall-clock // is what makes this a test of the fix rather than of the plumbing — and the in-flight counter // proves requests genuinely overlapped rather than the timing being luck. func TestArtifactChoices_IsConcurrent(t *testing.T) { const n = 16 vers := make([]string, n) for i := range vers { vers[i] = fmt.Sprintf("0.%d.0", 100-i) // already newest-first } f := &fakeGitea{versions: vers, delay: 50 * time.Millisecond} s := newTestWebServer(t, f.start(t)) start := time.Now() got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") elapsed := time.Since(start) if len(got) != n { t.Fatalf("got %d choices, want %d", len(got), n) } serial := time.Duration(n) * 50 * time.Millisecond // 800ms if elapsed > serial/2 { t.Errorf("took %v; a serial implementation would take ~%v and the concurrent one should be far "+ "under half of that. This is the 26-second Configuration page.", elapsed, serial) } if max := atomic.LoadInt32(&f.maxSeen); max < 2 { t.Errorf("max concurrent in-flight requests was %d — the lookups did not actually overlap, so a "+ "fast wall-clock here would be luck rather than concurrency", max) } // and it must respect the bound rather than opening one connection per version if max := atomic.LoadInt32(&f.maxSeen); max > 8 { t.Errorf("max concurrent in-flight was %d, above the bound of 8 — a large package list would "+ "stampede Gitea", max) } } // The cap is unchanged: at most 20 versions are offered however many exist. func TestArtifactChoices_StillCapsAtTwenty(t *testing.T) { vers := make([]string, 30) for i := range vers { vers[i] = fmt.Sprintf("0.%d.0", 200-i) } f := &fakeGitea{versions: vers} s := newTestWebServer(t, f.start(t)) got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") if len(got) != 20 { t.Errorf("got %d choices, want the 20 cap", len(got)) } if atomic.LoadInt32(&f.callCount) > 21 { // 1 version list + 20 sha lookups t.Errorf("made %d requests; the cap must limit the FAN-OUT too, not just the rendered list", atomic.LoadInt32(&f.callCount)) } } // R-267: the dropdown is memoised for artifactChoicesTTL. A second load inside the window must make // NO further Gitea calls; a failed resolve must NOT be cached, or a blip would pin an empty list in // front of the operator for a minute. func TestArtifactChoices_CachedWithinTTL(t *testing.T) { f := &fakeGitea{versions: []string{"0.9.0", "0.8.0"}} s := newTestWebServer(t, f.start(t)) first := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") after := atomic.LoadInt32(&f.callCount) if after == 0 { t.Fatal("the first resolve made no calls") } second := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") if atomic.LoadInt32(&f.callCount) != after { t.Errorf("a second load inside the TTL hit Gitea again (%d -> %d calls) — the cache is not "+ "in the path", after, atomic.LoadInt32(&f.callCount)) } if len(second) != len(first) || second[0].Version != first[0].Version || second[0].SHA256 != first[0].SHA256 { t.Errorf("cached result differs from the fresh one: %+v vs %+v", second, first) } } func TestArtifactChoices_FailureIsNotCached(t *testing.T) { // every version fails → an empty list, which must NOT be remembered f := &fakeGitea{versions: []string{"0.9.0"}, failVer: "0.9.0"} s := newTestWebServer(t, f.start(t)) if got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent"); len(got) != 0 { t.Fatalf("precondition: expected an empty list, got %+v", got) } before := atomic.LoadInt32(&f.callCount) _ = s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") if atomic.LoadInt32(&f.callCount) == before { t.Error("an empty/failed resolve was cached — a Gitea blip would then show the operator an " + "empty dropdown for a full minute with no way to retry") } } // Two packages must not share a cache slot. func TestArtifactChoices_CacheIsPerPackage(t *testing.T) { f := &fakeGitea{versions: []string{"0.9.0"}} s := newTestWebServer(t, f.start(t)) a := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent") before := atomic.LoadInt32(&f.callCount) _ = s.artifactChoices(context.Background(), "felhom-golden", "golden.tar.zst") if atomic.LoadInt32(&f.callCount) == before { t.Error("a different package was served from the first package's cache entry") } if len(a) == 0 { t.Error("precondition") } }