fix: lifecycle methods need value receivers - app detail page 500 (v0.158.1)
Shipped in v0.158.0, caught live within the hour. /apps/<slug> returned 500
for EVERY app: html/template cannot call a pointer-receiver method on a
non-addressable value, and appDetailHandler passes Meta as a VALUE inside a
map[string]interface{}.
It compiled and every test passed because nothing rendered app_info - the
catalog tests used the funcmap route, which takes a value and works either
way. A template method call is only checked when the template runs.
Adds TestAppInfoRendersForEveryLifecycle with the handler's exact data shape.
This commit is contained in:
@@ -220,7 +220,7 @@ const (
|
||||
// or a state added in a later release, silently pull a working app out of every customer's catalog.
|
||||
// The gate that actually protects against installing something is `CanInstall`, and it is fed by
|
||||
// this same function, so the two can never disagree.
|
||||
func (m *Metadata) EffectiveLifecycle() string {
|
||||
func (m Metadata) EffectiveLifecycle() string {
|
||||
switch m.Lifecycle {
|
||||
case "", LifecycleAvailable:
|
||||
return LifecycleAvailable
|
||||
@@ -233,13 +233,20 @@ func (m *Metadata) EffectiveLifecycle() string {
|
||||
}
|
||||
}
|
||||
|
||||
// VALUE receivers on all three, deliberately: html/template cannot call a POINTER-receiver method
|
||||
// on a non-addressable value, and `.Meta` reaches the templates as a stacks.Metadata VALUE inside a
|
||||
// map[string]interface{}. With a pointer receiver `{{if .Meta.IsAbandoned}}` fails at RENDER time
|
||||
// with "can't evaluate field IsAbandoned in type interface {}" — which is a 500 on the page, not a
|
||||
// compile error, so nothing catches it until someone loads the page. That is exactly how it shipped
|
||||
// once (v0.158.0, caught live the same hour). Keep these value receivers.
|
||||
//
|
||||
// CanInstall reports whether this app may be offered/installed. The catalog listing and the deploy
|
||||
// endpoint MUST both use this — a template excluded from the list but accepted by a direct POST
|
||||
// would be a gate in name only.
|
||||
func (m *Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
|
||||
func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
|
||||
|
||||
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
|
||||
func (m *Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
|
||||
func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
|
||||
|
||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||
// Returns default metadata if the file doesn't exist.
|
||||
|
||||
@@ -136,3 +136,61 @@ func TestAvailableAppHasNoBadgeAndKeepsInstallButton(t *testing.T) {
|
||||
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</a>")
|
||||
if got != c.wantBtn {
|
||||
t.Errorf("lifecycle %q: Telepítés button present=%v, want %v", c.lifecycle, got, c.wantBtn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user