Files
felhom-controller/controller/internal/web/app_page_test.go
T
admin fe0228c877
gates / gates (push) Failing after 46s
v0.315.0: style the app page's tab bar, and reuse the hero classes that already have rules
FOUND IN A BROWSER, and findable no other way. Every endpoint-level marker passed and the page was
unusable: the tab bar rendered as four bare underlined links run together, and the app logo filled a
third of the screen. The shell had been written with new class names — app-hero, app-hero-logo,
app-tabs — and only the first two of those had any CSS at all, under the names the design system
already uses.

  - the shell now reuses .app-info-hero / .app-info-logo / .app-info-hero-text, which carry the
    80px logo box, the hairline border and the 2px radius. Inventing a parallel name is what left
    the logo unsized; REUSE.md's first rule, learned again.
  - .app-tabs / .app-tab / .app-tab-active are genuinely new and are now styled to design system
    v2: 2px radius, hairline --line border, no shadow, --text-2 for an inactive tab and
    --blue-bright only for the one the household is on (blue marks where you are, not a deviation),
    the active tab sitting on the bar's own line, and a narrower gutter under 640px.
  - .after-start-card gets a blue left hairline — it is a request, not a fault, so no amber.
  - .app-faq gets its dt/dd rules.

TestAppPage_DeployedDefaultsToSettings sliced the header at `class="app-hero"`; it now slices at the
reused class and fails loudly if either marker is gone, instead of slicing on -1. The 36 app-page
parity fixtures are recaptured — and no other fixture moved, which is the evidence that the shell
change reaches only the app page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:04:57 +02:00

586 lines
26 KiB
Go

package web
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// THE APP PAGE (v0.315.0) — routing, the default tab, the old URLs, and the after-start registry.
//
// Every test here goes through s.ServeHTTP, the production route switch, because the thing under
// test IS the routing: which tab a URL renders, which method gets a redirect and which gets its
// handler. A test that called the tab renderers directly would assert nothing about any of that.
//
// Assertions are on the ASCII `data-*` markers the templates carry for the purpose, never on
// accented Hungarian: an accented pattern through a test host's encoding returns a false negative
// that reads exactly like the markup being gone (.claude/rules/ui-hungarian.md).
// appPageHarness builds a server with two apps: `adventurelog` installed and running, `grocy`
// present in the catalog and not installed. Both carry a slug that differs from nothing — the
// name/slug resolution is exercised by TestAppPage_OldURLsRedirectGETOnly's own app.
func appPageHarness(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
// ScanStacks ends by asking `docker ps -a` for container status, and the app's STATE is what the
// Mukodes card branches on. The stub answers that one call the way a running AdventureLog would —
// the five tab-separated fields refreshStatusLocked parses — so the page under test is the page a
// household with a running app sees, and the status path is exercised rather than stubbed away.
// Every other docker call exits 0.
dockerStub := "#!/bin/sh\n" +
"if [ \"$1\" = ps ]; then\n" +
" printf 'adventurelog\\tbusybox\\trunning\\tUp 2 hours\\tadventurelog\\n'\n" +
"fi\n" +
"exit 0\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(dockerStub), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
lg := log.New(io.Discard, "", 0)
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
write := func(app, name, body string) {
d := filepath.Join(cfg.Paths.StacksDir, app)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// Installed, running, with a subdomain so the shell's „Megnyitas" has something to point at.
write("adventurelog", "docker-compose.yml", "services:\n adventurelog:\n image: busybox\n")
write("adventurelog", ".felhom.yml", "display_name: AdventureLog\nslug: adventurelog\nsubdomain: utak\n"+
"app_info:\n tagline: Utinaplo\n")
write("adventurelog", "app.yaml", "deployed: true\nenv:\n SUBDOMAIN: utak\n")
// Not installed.
write("grocy", "docker-compose.yml", "services:\n grocy:\n image: busybox\n")
write("grocy", ".felhom.yml", "display_name: Grocy\nslug: grocy\napp_info:\n tagline: Keszlet\n")
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
_ = mgr.ScanStacks()
for _, n := range []string{"adventurelog", "grocy"} {
if _, ok := mgr.GetStack(n); !ok {
t.Fatalf("%s was not discovered by ScanStacks", n)
}
}
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
s.loadTemplates()
return s
}
func appGet(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
// ── A ─────────────────────────────────────────────────────────────────────────────────────────────
// TestAppPage_DeployedDefaultsToSettings — an installed app opens on „Beallitasok", at its own URL,
// with the four tabs; Mukodes offers the state changes and the HEADER offers none of them.
//
// RED-PROOF (REPORT.md): make the default tab the guide for a deployed app (appPageHandler's
// `tab = appTabSettings`) → the data-tab/aria-selected assertion fails. Reverted.
func TestAppPage_DeployedDefaultsToSettings(t *testing.T) {
s := appPageHarness(t)
rec := appGet(t, s, "/apps/adventurelog")
if rec.Code != http.StatusOK {
t.Fatalf("GET /apps/adventurelog = %d, want 200: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The settings tab is the one showing, and it says so in the markup the household's browser reads.
if !strings.Contains(body, `data-tab="settings"`) {
t.Error("the settings tab link is missing from the tab bar")
}
if !strings.Contains(body, `data-tab="settings"`+"\n") && !strings.Contains(body, `aria-selected="true"`) {
t.Error("no tab is marked as the selected one")
}
settingsTab := body[strings.Index(body, `data-tab="settings"`):]
settingsTab = settingsTab[:strings.Index(settingsTab, "</a>")]
if !strings.Contains(settingsTab, `aria-selected="true"`) {
t.Errorf("the SETTINGS tab must be the selected one, got: %q", settingsTab)
}
for _, tab := range []string{"settings", "data", "guide", "logs"} {
if !strings.Contains(body, `data-tab="`+tab+`"`) {
t.Errorf("tab %q is missing from an installed app's tab bar", tab)
}
}
// Mukodes carries the state changes.
if !strings.Contains(body, `data-run-card="1"`) {
t.Error("the Mukodes card is missing")
}
for _, act := range []string{"restart", "stop"} {
if !strings.Contains(body, `data-run-action="`+act+`"`) {
t.Errorf("a running app's Mukodes card must offer %q", act)
}
}
if strings.Contains(body, `data-run-action="start"`) {
t.Error("a RUNNING app must not be offered Ind:tas")
}
// The header does not. This is the whole point of the Mukodes card: the three buttons used to
// sit in the page header and read as menu items.
// The header is everything before the hero block; app-info-hero is the design system's existing
// hero class, which the shell reuses rather than inventing one with no rules behind it.
hi, hj := strings.Index(body, `class="page-header"`), strings.Index(body, `class="app-info-hero"`)
if hi < 0 || hj < 0 || hj < hi {
t.Fatalf("the shell's header/hero markers are gone (page-header=%d app-info-hero=%d)", hi, hj)
}
header := body[hi:hj]
if strings.Contains(header, "stackAction(") {
t.Errorf("the page header must carry no state-change button, got: %q", header)
}
// Part 3: Raspberry Pi is no longer supported, so neither badge may appear.
for _, gone := range []string{"Pi kompatibilis", "Runs on Pi", "Csak x86", "x86 only"} {
if strings.Contains(body, gone) {
t.Errorf("the removed Pi/x86 badge text %q is still on the page", gone)
}
}
}
// ── B ─────────────────────────────────────────────────────────────────────────────────────────────
// TestAppPage_NotDeployedShowsGuideOnly — a not-installed app has ONE view and one URL. The tab bar
// is absent (a bar of one tab is furniture) and every tab suffix answers 302 to the base.
//
// RED-PROOF (REPORT.md): drop the `!found.Deployed` redirect branch → /apps/grocy/settings answers
// 200 and the status assertion fails. Reverted.
func TestAppPage_NotDeployedShowsGuideOnly(t *testing.T) {
s := appPageHarness(t)
rec := appGet(t, s, "/apps/grocy")
if rec.Code != http.StatusOK {
t.Fatalf("GET /apps/grocy = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, tab := range []string{"settings", "data", "guide", "logs"} {
if strings.Contains(body, `data-tab="`+tab+`"`) {
t.Errorf("a not-installed app must have no tab bar, but tab %q is there", tab)
}
}
if !strings.Contains(body, `data-install-cta="1"`) {
t.Error("a not-installed, installable app must offer Telepites")
}
if strings.Contains(body, `data-run-card="1"`) {
t.Error("a not-installed app has nothing to start or stop")
}
for _, tab := range []string{"settings", "data", "guide", "logs"} {
rec := appGet(t, s, "/apps/grocy/"+tab)
if rec.Code != http.StatusFound {
t.Errorf("GET /apps/grocy/%s = %d, want 302", tab, rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/apps/grocy" {
t.Errorf("GET /apps/grocy/%s redirected to %q, want /apps/grocy", tab, loc)
}
}
// An unknown app is a 404 on every tab URL, as it always was.
for _, p := range []string{"/apps/nosuchapp", "/apps/nosuchapp/settings", "/apps/nosuchapp/logs"} {
if rec := appGet(t, s, p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", p, rec.Code)
}
}
// An unknown SUFFIX is not a tab, and must not be swallowed as part of the slug.
if rec := appGet(t, s, "/apps/grocy/nonsense"); rec.Code != http.StatusNotFound {
t.Errorf("GET /apps/grocy/nonsense = %d, want 404", rec.Code)
}
}
// ── C ─────────────────────────────────────────────────────────────────────────────────────────────
// TestAppPage_OldURLsRedirectGETOnly — the four old app URLs answer 302 (never 301) to the tab that
// now holds them, and the POSTs on those same paths still reach their handlers.
//
// RED-PROOF (REPORT.md): remove `&& r.Method == http.MethodGet` from the /stacks/…/backup redirect
// case → the POST is answered with a 302 to the data tab and the save-reaches-its-handler assertion
// fails. Reverted.
func TestAppPage_OldURLsRedirectGETOnly(t *testing.T) {
s := appPageHarness(t)
for _, c := range []struct{ from, want string }{
{"/stacks/adventurelog/logs", "/apps/adventurelog/logs"},
{"/stacks/adventurelog/export", "/apps/adventurelog/data"},
{"/stacks/adventurelog/backup", "/apps/adventurelog/data"},
// Installed → the settings tab. Not installed → the install wizard.
{"/stacks/adventurelog/deploy", "/apps/adventurelog/settings"},
{"/stacks/grocy/deploy", "/apps/grocy/install"},
} {
rec := appGet(t, s, c.from)
if rec.Code != http.StatusFound {
t.Errorf("GET %s = %d, want 302 (a 301 is cached by the browser forever)", c.from, rec.Code)
}
if got := rec.Header().Get("Location"); got != c.want {
t.Errorf("GET %s redirected to %q, want %q", c.from, got, c.want)
}
}
// The query string travels: a logs page open since before the update polls with ?raw=1.
rec := appGet(t, s, "/stacks/adventurelog/logs?raw=1")
if got := rec.Header().Get("Location"); got != "/apps/adventurelog/logs?raw=1" {
t.Errorf("the redirect dropped the query: Location=%q", got)
}
// And the tab itself still answers plain text for that poller.
raw := appGet(t, s, "/apps/adventurelog/logs?raw=1")
if ct := raw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("?raw=1 on the logs tab returned Content-Type %q, want text/plain", ct)
}
// An unknown stack name is a 404, not a redirect to an invented slug.
if rec := appGet(t, s, "/stacks/nosuchapp/logs"); rec.Code != http.StatusNotFound {
t.Errorf("GET /stacks/nosuchapp/logs = %d, want 404", rec.Code)
}
// THE POSTS ARE UNTOUCHED. Each must reach its handler — the redirect must never see a POST.
for _, p := range []string{"/stacks/adventurelog/backup", "/stacks/adventurelog/app-email"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, p, strings.NewReader("enabled=on"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.ServeHTTP(rec, req)
if rec.Code == http.StatusFound && strings.HasPrefix(rec.Header().Get("Location"), "/apps/") {
t.Errorf("POST %s was redirected to %q — a save must reach its handler, not a page",
p, rec.Header().Get("Location"))
}
}
}
// ── D and E: the after-start registry ─────────────────────────────────────────────────────────────
// afterStartHarness is appPageHarness with one app whose catalog declares an after-start field.
// `slug` and `env` say which, so one harness serves both the registered case (plex/PLEX_CLAIM) and
// the unregistered one.
func afterStartHarness(t *testing.T, slug, env string, gateClosed bool) *Server {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
lg := log.New(io.Discard, "", 0)
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
app := filepath.Join(cfg.Paths.StacksDir, slug)
if err := os.MkdirAll(app, 0o755); err != nil {
t.Fatal(err)
}
write := func(n, b string) {
if err := os.WriteFile(filepath.Join(app, n), []byte(b), 0o644); err != nil {
t.Fatal(err)
}
}
write("docker-compose.yml", "services:\n "+slug+":\n image: busybox\n")
write(".felhom.yml", "display_name: "+slug+"\nslug: "+slug+"\nsetup_gate: true\n"+
"deploy_fields:\n - env_var: "+env+"\n label: Kod\n type: text\n after_start: true\n placeholder: claim-xxxx\n")
gate := "open"
if gateClosed {
gate = "closed"
}
write("app.yaml", "deployed: true\nsetup_gate:\n state: "+gate+"\n since: \"2026-10-01T00:00:00Z\"\n")
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
_ = mgr.ScanStacks()
if _, ok := mgr.GetStack(slug); !ok {
t.Fatalf("%s not discovered", slug)
}
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
s.loadTemplates()
return s
}
// TestAfterStart_PlexThroughRouter (Scenario D) — the household's code goes in at
// POST /apps/plex/after-start/PLEX_CLAIM, through the PRODUCTION ROUTER and the REAL registry, to a
// fake Plex that answers the way the measured one does. The card is there while the gate is closed
// and gone once Plex says claimed.
//
// The registry wiring in server.go is asserted BY THIS TEST and not by an injected registry — an
// injected one would pass with the route missing, which is the seam-built-but-never-wired class.
//
// RED-PROOF (REPORT.md): remove the `/after-start/` case from server.go → the POST 404s and the
// claim assertion fails. Reverted.
func TestAfterStart_PlexThroughRouter(t *testing.T) {
hosts := fakePlex(t)
s := afterStartHarness(t, "plex", "PLEX_CLAIM", true)
// The card is offered: the field is declared, the action is registered, and it is still needed.
body := appGet(t, s, "/apps/plex").Body.String()
if !strings.Contains(body, `data-afterstart="PLEX_CLAIM"`) {
t.Fatalf("the after-start card is missing while the gate is closed")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/apps/plex/after-start/PLEX_CLAIM",
strings.NewReader(`{"value":"claim-felhomtestcode0000"}`))
req.Header.Set("Content-Type", "application/json")
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST after-start = %d, want 200: %s", rec.Code, rec.Body.String())
}
// The call actually reached the fake — by ADDRESS, which is what R-1060 fixed.
if len(*hosts) == 0 {
t.Fatal("no call reached the fake Plex — the registry did not run the claim")
}
for _, h := range *hosts {
if !strings.HasPrefix(h, "10.9.8.7:") {
t.Errorf("a call to Plex carried Host %q, not its address", h)
}
}
// The gate moved, and the card is gone with it — Plex itself said claimed.
st, _ := s.stackMgr.GetStack("plex")
if st.AppConfig != nil && st.AppConfig.SetupGate.Closed() {
t.Error("the setup gate is still closed after a successful claim")
}
if after := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(after, `data-afterstart="PLEX_CLAIM"`) {
t.Error("the card is still shown after the claim succeeded")
}
}
// TestAfterStart_NoActionNoCard (Scenario E) — THE WRONG CASE. A catalog template declares an
// after-start field for an app this repository has no action for. Nothing renders, nothing runs, the
// POST is a 404, and the fake records zero calls: a catalog file cannot make the box do anything.
//
// RED-PROOF (REPORT.md): drop the `!ok` guard in afterStartFields → the card renders for an app with
// no action and the first assertion fails; drop it in appAfterStartHandler → the POST stops being a
// 404. Both reverted.
func TestAfterStart_NoActionNoCard(t *testing.T) {
hosts := fakePlex(t)
// A real-looking app with an after-start field nobody registered an action for.
s := afterStartHarness(t, "grocy", "GROCY_TOKEN", true)
if body := appGet(t, s, "/apps/grocy").Body.String(); strings.Contains(body, `data-afterstart=`) {
t.Error("a field with no registered action must render NO card")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/apps/grocy/after-start/GROCY_TOKEN",
strings.NewReader(`{"value":"anything at all"}`))
req.Header.Set("Content-Type", "application/json")
s.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("POST to an unregistered after-start field = %d, want 404", rec.Code)
}
// A registered env var on the WRONG app is just as inert — the key is slug AND env var.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/apps/grocy/after-start/PLEX_CLAIM",
strings.NewReader(`{"value":"claim-felhomtestcode0000"}`))
req.Header.Set("Content-Type", "application/json")
s.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("POST /apps/grocy/after-start/PLEX_CLAIM = %d, want 404", rec.Code)
}
if len(*hosts) != 0 {
t.Errorf("%d call(s) reached Plex from an app with no registered action: %v", len(*hosts), *hosts)
}
}
// TestAfterStart_RegistryAloneIsNotAnInvitation — the OTHER half of the handler's gate, and the one
// the registry check cannot stand in for: the action IS registered (plex/PLEX_CLAIM), but THIS box's
// catalog template does not declare the field. Nothing may run.
//
// It is tested on its own because the obvious case does not reach it: a POST naming an app with no
// registry entry is refused by the `!ok` check first, so the `declared` check was green and unpinned
// until this test existed — the comment-asserting-an-invariant class (CLAUDE.md).
//
// RED-PROOF (REPORT.md): drop `!declared` from appAfterStartHandler's guard → the POST answers 200
// and the fake records a claim call. Reverted. (Dropping it with only TestAfterStart_NoActionNoCard
// in place left the suite GREEN, which is why this test was written.)
func TestAfterStart_RegistryAloneIsNotAnInvitation(t *testing.T) {
hosts := fakePlex(t)
// Plex, with its setup gate closed, but WITHOUT the after_start field in its template — the state
// every box was in before the catalog carried the field, and the state a box is in whose catalog
// sync has not landed yet.
s := afterStartHarness(t, "plex", "PLEX_CLAIM", true)
undeclared := "display_name: plex\nslug: plex\nsetup_gate: true\n"
if err := os.WriteFile(filepath.Join(s.cfg.Paths.StacksDir, "plex", ".felhom.yml"), []byte(undeclared), 0o644); err != nil {
t.Fatal(err)
}
if err := s.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/apps/plex/after-start/PLEX_CLAIM",
strings.NewReader(`{"value":"claim-felhomtestcode0000"}`))
req.Header.Set("Content-Type", "application/json")
s.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("POST for a field the catalog does not declare = %d, want 404", rec.Code)
}
if len(*hosts) != 0 {
t.Errorf("%d call(s) reached Plex for an UNDECLARED field: %v", len(*hosts), *hosts)
}
// And the gate it would have opened is untouched.
st, _ := s.stackMgr.GetStack("plex")
if st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() {
t.Error("the setup gate moved although nothing was allowed to run")
}
// No card either, since the field is not declared.
if body := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(body, `data-afterstart=`) {
t.Error("a card rendered for a field the catalog does not declare")
}
}
// TestAfterStart_CardHiddenWhenNotNeeded — the other branch of the registry's gate: the action is
// registered, but it says the value is no longer wanted.
func TestAfterStart_CardHiddenWhenNotNeeded(t *testing.T) {
s := afterStartHarness(t, "plex", "PLEX_CLAIM", false) // the gate is OPEN: already claimed
if body := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(body, `data-afterstart="PLEX_CLAIM"`) {
t.Error("the card must be hidden once the action says the value is not needed")
}
}
// TestAfterStart_PlexClaimOldRouteStillWorks — the route the page carried before v0.315.0 runs the
// same registry entry, so a tab open across the update still works.
func TestAfterStart_PlexClaimOldRouteStillWorks(t *testing.T) {
hosts := fakePlex(t)
s := afterStartHarness(t, "plex", "PLEX_CLAIM", true)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/apps/plex/plex-claim", strings.NewReader(`{"code":"claim-felhomtestcode0000"}`))
req.Header.Set("Content-Type", "application/json")
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST /apps/plex/plex-claim = %d, want 200: %s", rec.Code, rec.Body.String())
}
var got struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || !got.OK {
t.Fatalf("the old route's answer changed shape: %s (%v)", rec.Body.String(), err)
}
if len(*hosts) == 0 {
t.Error("the old route did not reach Plex")
}
}
// ── G ─────────────────────────────────────────────────────────────────────────────────────────────
// TestInstallPage_BackGoesToApp (Scenario G) — the wizard's Vissza and Megsem land on the APP, not
// on the list. Pressing „back" and losing the app was the complaint this release is about.
//
// RED-PROOF (REPORT.md): point either href at /stacks → the matching assertion fails. Reverted.
func TestInstallPage_BackGoesToApp(t *testing.T) {
s := appPageHarness(t)
rec := appGet(t, s, "/apps/grocy/install")
if rec.Code != http.StatusOK {
t.Fatalf("GET /apps/grocy/install = %d, want 200: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, marker := range []string{`data-install-back="1"`, `data-install-cancel="1"`} {
i := strings.Index(body, marker)
if i < 0 {
t.Errorf("the install page has no %s", marker)
continue
}
// The href is on the same tag, before the marker.
tag := body[strings.LastIndex(body[:i], "<a "):i]
if !strings.Contains(tag, `href="/apps/grocy"`) {
t.Errorf("%s points at %q, want /apps/grocy", marker, tag)
}
}
// After a successful install the wizard's own links point at the app's tabs.
for _, want := range []string{`/apps/grocy/settings`, `/apps/grocy/logs`} {
if !strings.Contains(body, want) {
t.Errorf("the post-install card must link %s", want)
}
}
// An unknown app has no install page.
if rec := appGet(t, s, "/apps/nosuchapp/install"); rec.Code != http.StatusNotFound {
t.Errorf("GET /apps/nosuchapp/install = %d, want 404", rec.Code)
}
}
// ── the tabs' own content ─────────────────────────────────────────────────────────────────────────
// TestAppPage_EachTabRendersItsOwnContent — one marker per tab, so a tab that silently renders
// another tab's body (or nothing) is caught. The log poller is the sharpest of them: it must run on
// the log tab and NOWHERE else, which is the reason these are four URLs and not four hidden panels.
func TestAppPage_EachTabRendersItsOwnContent(t *testing.T) {
s := appPageHarness(t)
for _, c := range []struct {
tab string
want []string
mustNot []string
wantPoll bool
}{
{tab: "settings", want: []string{`data-run-card="1"`}, mustNot: []string{`data-tab-panel="logs"`}},
{tab: "data", want: []string{`class="app-data-tab"`}, mustNot: []string{`data-run-card="1"`, `data-tab-panel="logs"`}},
{tab: "guide", want: []string{`data-guide-missing="1"`}, mustNot: []string{`data-run-card="1"`, `data-tab-panel="logs"`}},
{tab: "logs", want: []string{`data-tab-panel="logs"`}, mustNot: []string{`data-run-card="1"`}, wantPoll: true},
} {
rec := appGet(t, s, "/apps/adventurelog/"+c.tab)
if rec.Code != http.StatusOK {
t.Errorf("GET /apps/adventurelog/%s = %d, want 200", c.tab, rec.Code)
continue
}
body := rec.Body.String()
for _, w := range c.want {
if !strings.Contains(body, w) {
t.Errorf("the %s tab is missing its own marker %s", c.tab, w)
}
}
for _, w := range c.mustNot {
if strings.Contains(body, w) {
t.Errorf("the %s tab carries %s, which belongs to another tab", c.tab, w)
}
}
// The poller: `setInterval(window.fetchLogs` is the live log loop.
if poll := strings.Contains(body, "setInterval(window.fetchLogs"); poll != c.wantPoll {
t.Errorf("the %s tab runs the log poller=%v, want %v", c.tab, poll, c.wantPoll)
}
// The shell is on every tab, and so is its tab bar.
if !strings.Contains(body, `data-tab="`+c.tab+`"`) {
t.Errorf("the %s tab has no tab bar", c.tab)
}
}
}