package web import ( "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // Slice 2 (v0.233.0) — the badge. FOUR states, and the fourth is the load-bearing one: // NO RECORD RENDERS NOTHING. An app with no record showing "Naprakész" is the R-166 failure in a // new place — absent means UNKNOWN and never means current. var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) // ubStack builds a deployed app whose record and template pins are stated explicitly. func ubStack(installed map[string]stacks.InstalledImage, template map[string]string, since string) stacks.Stack { return stacks.Stack{ Name: "bookstack", Deployed: true, State: stacks.StateRunning, Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since}, AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed}, TemplateImages: template, } } func rec(ref string) stacks.InstalledImage { return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"} } // --- GROUP D: the four states --- // TestGroupD_FourStates. // // COMPANION RED-PROOF (run 2026-09-02): in compareInstalledToTemplate, change the // `len(s.AppConfig.InstalledImages) == 0` guard to fall through to updateCurrent instead of // updateUnknown — i.e. the trivial implementation that treats "we never wrote it down" as // "up to date". The `no record at all` sub-test then fails with a "Naprakész" badge on an app // nobody has ever measured. Reverted. func TestGroupD_FourStates(t *testing.T) { tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2", "db": "mariadb:12.3"} cases := []struct { name string stack stacks.Stack wantBadge bool wantLabel string wantClass string }{ { name: "current", stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, tpl, "2026-07-18"), wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok", }, { name: "behind, age known", stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, "2026-07-18"), wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn", }, { name: "behind, age unknown", stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, ""), wantBadge: true, wantLabel: "Frissítés elérhető", wantClass: "tag-warn", }, { name: "behind, catalog moved TODAY", stack: ubStack(map[string]stacks.InstalledImage{"web": rec("old:1"), "db": rec(tpl["db"])}, tpl, "2026-09-02"), wantBadge: true, wantLabel: "Frissítés elérhető — ma", wantClass: "tag-warn", }, { name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered", stack: ubStack(nil, tpl, "2026-07-18"), wantBadge: false, }, { name: "a service was ADDED by the template", stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"), wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn", }, { name: "template unreadable — nothing rendered", stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, nil, "2026-07-18"), wantBadge: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { b := updateBadgeAt(c.stack, badgeNow) if (b != nil) != c.wantBadge { t.Fatalf("badge = %+v, want present=%v", b, c.wantBadge) } if b == nil { return } if b.Label != c.wantLabel { t.Errorf("label = %q, want %q", b.Label, c.wantLabel) } if b.Class != c.wantClass { t.Errorf("class = %q, want %q", b.Class, c.wantClass) } if b.Title == "" { t.Error("a badge that is only a word is a riddle — it must carry an explanation") } }) } } // TestGroupD_NoVersionNumberIsEverShown — the operator ruled it: a household cannot act on // "26.05.2". Version strings stay in the logs, the API and the hub. func TestGroupD_NoVersionNumberIsEverShown(t *testing.T) { tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"} for _, s := range []stacks.Stack{ ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"), ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18"), } { b := updateBadgeAt(s, badgeNow) if b == nil { t.Fatal("expected a badge") } for _, forbidden := range []string{"26.05.2", "25.02.2", "bookstack:", "mariadb", "sha256"} { if strings.Contains(b.Label+b.Title, forbidden) { t.Errorf("badge text leaks %q: label=%q title=%q", forbidden, b.Label, b.Title) } } } } // TestGroupD_NothingIsBadgedThatCannotBeJudged — undeployed, protected and orphaned apps. func TestGroupD_NothingIsBadgedThatCannotBeJudged(t *testing.T) { tpl := map[string]string{"web": "nginx:1.27"} inst := map[string]stacks.InstalledImage{"web": rec("nginx:1.26")} for name, mutate := range map[string]func(*stacks.Stack){ "not deployed": func(s *stacks.Stack) { s.Deployed = false }, "protected": func(s *stacks.Stack) { s.Protected = true }, "orphaned": func(s *stacks.Stack) { s.Orphaned = true }, } { s := ubStack(inst, tpl, "2026-07-18") mutate(&s) if b := updateBadgeAt(s, badgeNow); b != nil { t.Errorf("%s: rendered %q — there is nothing to be current WITH", name, b.Label) } } } // --- GROUP D, rendered: the PRODUCTION templates --- func ubAppInfoData(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", } } func ubStacksData(st stacks.Stack) map[string]interface{} { return map[string]interface{}{ "Page": "stacks", "Title": "Alkalmazások", "Stacks": []stacks.Stack{st}, "MissingStorage": map[string]string{}, "NetworkWarnings": map[string]string{}, "NetworkStubs": map[string]string{}, "StorageLabels": map[string]string{}, "Subdomains": map[string]string{}, } } // TestGroupD_BadgeRendersOnBothSurfaces renders the REAL templates, which is the only thing that // catches a template-time failure: `.Stack` reaches app_info as a *stacks.Stack, and a funcmap entry // taking a VALUE has to be reachable from it. A compile-clean funcmap that 500s at render is exactly // how v0.158.0's pointer-receiver defect shipped. // // COMPANION RED-PROOF (run 2026-09-02): delete the {{template "meta_badge" (updateBadge …)}} line // from stacks.html and the "app list" sub-test fails; delete it from app_info.html and the "app page" // sub-test fails. Reverted. func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) { tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"} behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18") current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18") legacy := ubStack(nil, tpl, "2026-07-18") for _, surface := range []struct { name string data func(stacks.Stack) map[string]interface{} tmpl string }{ {"app page", ubAppInfoData, "app_info"}, {"app list", ubStacksData, "stacks"}, } { t.Run(surface.name, func(t *testing.T) { h := renderBackupPage(t, surface.tmpl, surface.data(behind)) if !strings.Contains(h, "Frissítés elérhető — 46 napja") { t.Errorf("the behind badge is missing from %s", surface.tmpl) } h = renderBackupPage(t, surface.tmpl, surface.data(current)) if !strings.Contains(h, "Naprakész") { t.Errorf("the current badge is missing from %s", surface.tmpl) } // The negative half. Without it, an implementation that badges everything passes. h = renderBackupPage(t, surface.tmpl, surface.data(legacy)) if strings.Contains(h, "Naprakész") || strings.Contains(h, "Frissítés elérhető") { t.Errorf("an app with NO RECORD must be badged with NOTHING on %s", surface.tmpl) } }) } } // --- SCENARIO E: nothing about updating changed --- // TestScenarioE_TheUpdateButtonIsUntouched. This slice is information only. The badge must be wired // to no action, and the three lifecycle buttons must render exactly as they did before it existed. func TestScenarioE_TheUpdateButtonIsUntouched(t *testing.T) { tpl := map[string]string{"web": "nginx:1.27"} states := map[string]stacks.Stack{ "current": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")}, tpl, "2026-07-18"), "behind": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, "2026-07-18"), "behind/na": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, ""), "no record": ubStack(nil, tpl, "2026-07-18"), } for name, st := range states { h := renderBackupPage(t, "stacks", ubStacksData(st)) for _, want := range []string{ `stackAction(event, 'bookstack', 'update')`, `stackAction(event, 'bookstack', 'restart')`, `stackAction(event, 'bookstack', 'stop')`, } { if !strings.Contains(h, want) { t.Errorf("%s: the %q button changed — this slice must change no behaviour", name, want) } } // No new action may hang off the badge. if strings.Contains(h, "updateBadgeAction") || strings.Contains(h, "'autoupdate'") { t.Errorf("%s: the badge must be wired to NOTHING", name) } } } // --- GROUP F: catalog_since tolerance --- // TestGroupF_CatalogSinceTolerance — absent, empty, malformed and FUTURE all degrade to "no age // known" and never brick the badge. The future case is not pedantry: a box whose clock is behind the // catalog would otherwise print "-3 napja". func TestGroupF_CatalogSinceTolerance(t *testing.T) { for _, since := range []string{"", " ", "tegnap", "18/07/2026", "2026-13-45", "2026-12-31"} { m := stacks.Metadata{CatalogSince: since} if days, ok := m.CatalogSinceAge(badgeNow); ok { t.Errorf("catalog_since %q must be UNUSABLE, got %d napja", since, days) } tpl := map[string]string{"web": "nginx:1.27"} s := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, since) b := updateBadgeAt(s, badgeNow) if b == nil { t.Fatalf("catalog_since %q: the badge must still render, just without an age", since) } if b.Label != "Frissítés elérhető" { t.Errorf("catalog_since %q: label = %q, want the age-less form", since, b.Label) } } // And the usable cases. for since, want := range map[string]int{"2026-09-02": 0, "2026-09-01": 1, "2026-07-18": 46} { got, ok := stacks.Metadata{CatalogSince: since}.CatalogSinceAge(badgeNow) if !ok || got != want { t.Errorf("catalog_since %q → (%d, %v), want (%d, true)", since, got, ok, want) } } }