Files
felhom-controller/controller/internal/web/app_page_test.go
T
admin e66d4669d6
gates / gates (push) Failing after 47s
v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

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

581 lines
25 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.
header := body[strings.Index(body, `class="page-header"`):]
header = header[:strings.Index(header, `class="app-hero"`)]
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)
}
}
}