package web import ( "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) func lcStack(name, lifecycle string, deployed bool) stacks.Stack { return stacks.Stack{ Name: name, Deployed: deployed, State: stacks.StateNotDeployed, Meta: stacks.Metadata{DisplayName: name, Slug: name, Lifecycle: lifecycle}, } } // TestVisibleCatalogStacks pins the catalog-listing rule on both axes at once: withdrawn templates // disappear from the OFFER list, and a deployed instance of the very same template stays. // // COMPANION RED-PROOF: drop the `st.Deployed ||` clause and the two "deployed" rows fail — a // customer's working app vanishes from their own Alkalmazások page, which is the failure that makes // withdrawing an app dangerous in the first place. func TestVisibleCatalogStacks(t *testing.T) { in := []stacks.Stack{ lcStack("bookstack", "", false), // available, not deployed → OFFERED lcStack("immich", "", true), // available, deployed → shown lcStack("plant-it", "abandoned", false), // withdrawn, not deployed → HIDDEN lcStack("plant-it-run", "abandoned", true), // withdrawn, DEPLOYED → shown lcStack("oldapp", "hidden", false), // withdrawn, not deployed → HIDDEN lcStack("oldapp-run", "hidden", true), // withdrawn, DEPLOYED → shown lcStack("typoapp", "bogus", false), // unknown → available → OFFERED (fail-open) } got := map[string]bool{} for _, st := range visibleCatalogStacks(in) { got[st.Name] = true } want := []string{"bookstack", "immich", "plant-it-run", "oldapp-run", "typoapp"} notWant := []string{"plant-it", "oldapp"} for _, n := range want { if !got[n] { t.Errorf("%q must be listed", n) } } for _, n := range notWant { if got[n] { t.Errorf("%q is withdrawn and not deployed — it must NOT be offered", n) } } if len(got) != len(want) { t.Errorf("listed %d stacks, want %d (%v)", len(got), len(want), got) } } // TestVisibleCatalogStacks_ProtectedAlwaysSurvives: infra stacks carry no catalog metadata and must // never be filtered out by a rule about catalog apps. func TestVisibleCatalogStacks_ProtectedAlwaysSurvives(t *testing.T) { st := lcStack("traefik", "hidden", false) st.Protected = true if len(visibleCatalogStacks([]stacks.Stack{st})) != 1 { t.Fatal("a protected infra stack must never be hidden by the lifecycle filter") } } func TestLifecycleBadge(t *testing.T) { cases := []struct { lifecycle string wantBadge bool }{ {"", false}, {"available", false}, // hidden renders NOTHING on a deployed app: "we stopped offering this" is not a fact the // customer running it needs, and a badge implying something is wrong would be misleading. {"hidden", false}, {"abandoned", true}, {"bogus", false}, } for _, c := range cases { b := lifecycleBadge(stacks.Metadata{Lifecycle: c.lifecycle}) if (b != nil) != c.wantBadge { t.Errorf("lifecycle %q: badge=%v, want present=%v", c.lifecycle, b, c.wantBadge) } if b != nil { if b.Label != "Nem karbantartott" { t.Errorf("label = %q", b.Label) } if b.Title == "" { t.Error("a badge that is only a word is a riddle — it must carry an explanation") } } } } // TestAbandonedBadgeRendersOnCatalogCard renders the PRODUCTION stacks template. // // COMPANION RED-PROOF: remove the {{template "meta_badge" ...}} line from stacks.html and this // fails — the badge is the only thing telling a customer the software is no longer maintained. func TestAbandonedBadgeRendersOnCatalogCard(t *testing.T) { deployed := lcStack("plant-it", "abandoned", true) deployed.State = stacks.StateRunning html := renderBackupPage(t, "stacks", map[string]interface{}{ "Page": "stacks", "Title": "Alkalmazások", "Stacks": []stacks.Stack{deployed}, "MissingStorage": map[string]string{}, "NetworkWarnings": map[string]string{}, "NetworkStubs": map[string]string{}, "StorageLabels": map[string]string{}, "Subdomains": map[string]string{}, }) if !strings.Contains(html, "Nem karbantartott") { t.Fatal("a deployed abandoned app must carry the Nem karbantartott badge on its card") } if strings.Contains(html, "Telepítés") { t.Error("an abandoned app must never offer a Telepítés button") } } // TestAvailableAppHasNoBadgeAndKeepsInstallButton is the negative half — without it, an impl that // badges everything would pass the test above. func TestAvailableAppHasNoBadgeAndKeepsInstallButton(t *testing.T) { html := renderBackupPage(t, "stacks", map[string]interface{}{ "Page": "stacks", "Title": "Alkalmazások", "Stacks": []stacks.Stack{lcStack("bookstack", "", false)}, "MissingStorage": map[string]string{}, "NetworkWarnings": map[string]string{}, "NetworkStubs": map[string]string{}, "StorageLabels": map[string]string{}, "Subdomains": map[string]string{}, }) if strings.Contains(html, "Nem karbantartott") { t.Error("an available app must not be badged") } if !strings.Contains(html, "Telepítés") { t.Fatal("an available, undeployed app must still offer Telepítés") } } // appInfoData mirrors appDetailHandler's data map — CRUCIALLY including `"Meta": st.Meta` as a // stacks.Metadata VALUE inside a map[string]interface{}, which is what the handler really does. // That detail is the entire point of this test: html/template cannot call a POINTER-receiver method // on a non-addressable value, so `{{if .Meta.IsAbandoned}}` renders fine in a unit test that passes // a *Metadata and 500s in production. Do not "simplify" this to a pointer. func appInfoData(st stacks.Stack) map[string]interface{} { return map[string]interface{}{ "Page": "stacks", "Title": st.Meta.DisplayName, "Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, "HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain, "Domain": "demo-felhom.eu", } } // TestAppInfoRendersForEveryLifecycle is the regression test for a defect that SHIPPED: v0.158.0's // first cut used pointer receivers, so `{{if .Meta.IsAbandoned}}` failed at RENDER time with // "can't evaluate field IsAbandoned in type interface {}" — a 500 on EVERY app detail page, // abandoned or not. It compiled, and every other test passed, because nothing rendered app_info. // // A template method call is only checked when the template runs. Render the page. func TestAppInfoRendersForEveryLifecycle(t *testing.T) { for _, lc := range []string{"", "available", "hidden", "abandoned", "bogus"} { t.Run("lifecycle="+lc, func(t *testing.T) { st := lcStack("someapp", lc, true) st.State = stacks.StateRunning html := renderBackupPage(t, "app_info", appInfoData(st)) if len(html) < 500 { t.Fatalf("app_info rendered only %d bytes — the page is broken", len(html)) } wantNotice := lc == "abandoned" gotNotice := strings.Contains(html, "felhagyott a fejlesztéssel") if gotNotice != wantNotice { t.Errorf("abandoned notice present=%v, want %v (lifecycle %q)", gotNotice, wantNotice, lc) } gotBadge := strings.Contains(html, "Nem karbantartott") if gotBadge != wantNotice { t.Errorf("badge present=%v, want %v (lifecycle %q)", gotBadge, wantNotice, lc) } }) } } // TestAppInfoHidesInstallButtonForWithdrawnApp — the undeployed view: a withdrawn app must not offer // Telepítés, and an available one must. func TestAppInfoHidesInstallButtonForWithdrawnApp(t *testing.T) { for _, c := range []struct { lifecycle string wantBtn bool }{{"", true}, {"available", true}, {"hidden", false}, {"abandoned", false}} { st := lcStack("someapp", c.lifecycle, false) html := renderBackupPage(t, "app_info", appInfoData(st)) got := strings.Contains(html, "Telepítés") if got != c.wantBtn { t.Errorf("lifecycle %q: Telepítés button present=%v, want %v", c.lifecycle, got, c.wantBtn) } } }