package webapp import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // stubReleaseServer returns a httptest.Server whose handler serves // the supplied tag_name as a GitHub-style JSON body. The close func // is returned alongside so callers can defer shutdown. func stubReleaseServer(t *testing.T, tagName string, status int) (*httptest.Server, func()) { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "application/json", r.Header.Get("Accept")) if status != http.StatusOK { w.WriteHeader(status) return } w.Header().Set("Content-Type", "application/json") body, _ := json.Marshal(map[string]string{"tag_name": tagName}) _, _ = w.Write(body) })) return ts, ts.Close } // TestReleasePoller_NoURLReturnsPlaceholder pins the // no-URL-is-configured fallback. The handler must not hit the // network when Config.ReleaseURL is empty. func TestReleasePoller_NoURLReturnsPlaceholder(t *testing.T) { p := &releasePoller{} got := p.latest(context.Background(), nil) assert.Equal(t, placeholderLatestVersion, got) } // TestReleasePoller_SuccessCaches verifies the happy path: the // first call hits the URL, subsequent calls within the TTL come // from the cache. func TestReleasePoller_SuccessCaches(t *testing.T) { ts, cleanup := stubReleaseServer(t, "v2.7.1", http.StatusOK) defer cleanup() p := &releasePoller{} p.setURL(ts.URL) got := p.latest(context.Background(), ts.Client()) assert.Equal(t, "v2.7.1", got) // Second call: cache hit. Replace the upstream with one that // would error; the cached value must still come back. p.url = "http://127.0.0.1:1/never-reachable" got = p.latest(context.Background(), ts.Client()) assert.Equal(t, "v2.7.1", got, "cached value must survive upstream failures inside the TTL window") } // TestReleasePoller_Non2xxReturnsPlaceholder ensures a 5xx upstream // does not poison the cache (placeholder shown, cache untouched). func TestReleasePoller_Non2xxReturnsPlaceholder(t *testing.T) { ts, cleanup := stubReleaseServer(t, "ignored", http.StatusInternalServerError) defer cleanup() p := &releasePoller{} p.setURL(ts.URL) got := p.latest(context.Background(), ts.Client()) assert.Equal(t, placeholderLatestVersion, got) // Confirm cache was not touched: a fresh request to a working // upstream must produce the placeholder if the broken one was // recorded. We use a different working upstream here. ts2, cleanup2 := stubReleaseServer(t, "v9.9.9", http.StatusOK) defer cleanup2() p.setURL(ts2.URL) got = p.latest(context.Background(), ts2.Client()) assert.Equal(t, "v9.9.9", got) } // TestReleasePoller_BadJSONReturnsPlaceholder verifies that a 200 // with a missing tag_name falls back to the placeholder. func TestReleasePoller_BadJSONReturnsPlaceholder(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"name": "no tag here"}`)) })) defer ts.Close() p := &releasePoller{} p.setURL(ts.URL) got := p.latest(context.Background(), ts.Client()) assert.Equal(t, placeholderLatestVersion, got) } // TestReleasePoller_TimeoutReturnsPlaceholder confirms a slow // upstream degrades to the placeholder without exceeding the // per-call timeout budget. func TestReleasePoller_TimeoutReturnsPlaceholder(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(2 * defaultReleasePollTimeout) _, _ = io.WriteString(w, `{"tag_name":"too-late"}`) })) defer ts.Close() p := &releasePoller{} p.setURL(ts.URL) start := time.Now() got := p.latest(context.Background(), &http.Client{Timeout: defaultReleasePollTimeout}) elapsed := time.Since(start) assert.Equal(t, placeholderLatestVersion, got) assert.Less(t, elapsed, 2*defaultReleasePollTimeout, "timeout must fire before the slow upstream replies") } // TestReleasePoller_TTLExpiry verifies that after releaseCacheTTL // the poller re-fetches the URL. We can't wait an hour in a unit // test, so we reset the cachedAt directly to a stale time and // confirm the next call refetches. func TestReleasePoller_TTLExpiry(t *testing.T) { ts, cleanup := stubReleaseServer(t, "v3.0.0", http.StatusOK) defer cleanup() p := &releasePoller{} p.setURL(ts.URL) // Prime the cache. got := p.latest(context.Background(), ts.Client()) require.Equal(t, "v3.0.0", got) // Force the cachedAt into the past. p.mu.Lock() p.cachedAt = time.Now().Add(-2 * releaseCacheTTL) p.mu.Unlock() // Change the upstream to a new tag — must be observed. ts2, cleanup2 := stubReleaseServer(t, "v3.0.1", http.StatusOK) defer cleanup2() p.setURL(ts2.URL) got = p.latest(context.Background(), ts2.Client()) assert.Equal(t, "v3.0.1", got, "stale cache must not block a fresh fetch after setURL reset") } // TestUpdatesPage_ShowsPlaceholder verifies that without // Config.ReleaseURL the page renders the placeholder string in // the "Latest known" cell. func TestUpdatesPage_ShowsPlaceholder(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) require.Empty(t, srv.cfg.ReleaseURL, "test fixture must not pre-set ReleaseURL") ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/updates") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) page := string(body) assert.Contains(t, page, placeholderLatestVersion, "placeholder version must appear when no release URL is configured") assert.Contains(t, page, "WORKER_RELEASE_URL", "placeholder copy must mention the env var that turns on real polls") } // TestUpdatesPage_RunsPollWithReleaseURL verifies that with // Config.ReleaseURL set, the page renders the tag_name from the // upstream release server. func TestUpdatesPage_RunsPollWithReleaseURL(t *testing.T) { ts, cleanup := stubReleaseServer(t, "v9.9.9", http.StatusOK) defer cleanup() srv := newTestServer(t, &stubRunner{id: "w-1"}) srv.cfg.ReleaseURL = ts.URL srv.releasePoller.setURL(ts.URL) hts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, hts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(hts.URL + "/updates") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) assert.Contains(t, string(body), "v9.9.9", "page must surface the polled tag_name when WORKER_RELEASE_URL is configured") } // TestChecksPage_RunNowDisabledButton verifies that the "Run now" // button is rendered and disabled, with the tooltip explaining the // gating. func TestChecksPage_RunNowDisabledButton(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/checks") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) page := string(body) assert.Contains(t, page, "Run now", "the Run now button must appear on the checks page") assert.Contains(t, page, "disabled", "the Run now button must be disabled in Phase 1") assert.Contains(t, page, "worker-notifier-mvp", "tooltip must cite the worker-notifier MVP plan that owns the hint protocol") } // TestNotificationsPage_ResendDisabledButton mirrors the checks // page test for the resend button on /notifications. func TestNotificationsPage_ResendDisabledButton(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/notifications") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) page := string(body) assert.Contains(t, page, "Resend") assert.Contains(t, page, "disabled") assert.Contains(t, page, "worker-notifier-mvp") } // TestAppsPage_ReferencesInventoryPlan ensures the copy on // /apps points operators at the deploymentd-driven inventory // surface that this PR's plan docs describe. func TestAppsPage_ReferencesInventoryPlan(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/apps") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) page := string(body) assert.Contains(t, page, "deploymentd", "apps page must mention deploymentd so operators know where Docker Compose discoveries land") assert.Contains(t, page, "inventory-management.md", "apps page must cite the inventory-management plan doc") } // _ = url.Values and strings.Builder keep imports used if the file // shrinks in future refactors; they document the surface without // affecting compilation. var ( _ = url.Values{} _ = strings.Builder{} )