e66d4669d6
gates / gates (push) Failing after 47s
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
157 lines
6.6 KiB
Go
157 lines
6.6 KiB
Go
package web
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// appInfoWithCards overlays the R-75 folder card onto the shared app_info fixture. It reuses
|
|
// appInfoData (lifecycle_test.go) deliberately — that helper passes a *Metadata for a reason
|
|
// documented there (value vs pointer receivers at render time); do not fork it.
|
|
func appInfoWithCards(st stacks.Stack, cards []DataPathCard) map[string]interface{} {
|
|
d := appInfoData(st)
|
|
if len(cards) > 0 {
|
|
d["DataPathCards"] = cards
|
|
}
|
|
return d
|
|
}
|
|
|
|
// SEAM-WIRING RULE: a conditional affordance ships with a render test PER BRANCH of its gate.
|
|
// Branch 1 — cards present: the block renders, with the label, the deep link and the copy.
|
|
func TestAppInfo_DataPathCardRenders(t *testing.T) {
|
|
st := stacks.Stack{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
|
|
Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless-ngx"}}
|
|
cards := []DataPathCard{{
|
|
Label: "Beolvasandó dokumentumok",
|
|
Link: importFolderLink("demo-felhom.eu", "paperless"),
|
|
Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, bundleMsg(t, "hu")),
|
|
IsImport: true,
|
|
FreeSpace: "44.8 GB szabad",
|
|
}}
|
|
html := renderBackupPage(t, "app_data", appInfoWithCards(st, cards))
|
|
|
|
if !strings.Contains(html, "Hova tegyem a fájlokat?") {
|
|
t.Error("the folder card heading must render")
|
|
}
|
|
if !strings.Contains(html, "Beolvasandó dokumentumok") {
|
|
t.Error("the catalog label must render")
|
|
}
|
|
// The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is
|
|
// what would have caught the v0.150.0 class of bug (a key rendered where another belonged).
|
|
if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") {
|
|
t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath"))
|
|
}
|
|
if !strings.Contains(html, "nem készül róla biztonsági mentés") {
|
|
t.Error("an excluded drop-zone must say it is unbacked")
|
|
}
|
|
if !strings.Contains(html, "44.8 GB szabad") {
|
|
t.Error("the system-drive free space must render on an import row")
|
|
}
|
|
// The copy must not promise a single click — a cold deep link goes through the FileBrowser login.
|
|
if !strings.Contains(html, "be kell jelentkezned") {
|
|
t.Error("the card must warn that a FileBrowser sign-in may be needed")
|
|
}
|
|
}
|
|
|
|
// Branch 2 — no cards: nothing renders, and in particular no empty heading.
|
|
func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) {
|
|
st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning,
|
|
Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
|
|
html := renderBackupPage(t, "app_data", appInfoWithCards(st, nil))
|
|
if strings.Contains(html, "Hova tegyem a fájlokat?") {
|
|
t.Error("an app with no data_paths must not render the folder card")
|
|
}
|
|
if strings.Contains(html, "datapath-row") {
|
|
t.Error("no folder rows must render")
|
|
}
|
|
}
|
|
|
|
// bundleMsg is the production lookup the handler uses, bound to one language.
|
|
func bundleMsg(t *testing.T, lang string) func(string) string {
|
|
t.Helper()
|
|
b, err := i18n.Shared()
|
|
if err != nil {
|
|
t.Fatalf("bundle: %v", err)
|
|
}
|
|
return func(key string) string { return b.Msg(lang, key) }
|
|
}
|
|
|
|
// Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do
|
|
// not make. `excluded` is dropped at every tier — it must say so.
|
|
//
|
|
// R-590 (v0.258.0): the sentence now follows the household's language, so the CONSEQUENCE is
|
|
// asserted in BOTH — the mechanism (does it read the bundle?) is not the property that matters. It
|
|
// is a promise about the customer's files: an English household that cannot read „nem készül róla
|
|
// biztonsági mentés" may leave originals in a drop-zone the backup filter discards at every tier.
|
|
func TestConsequenceIsClassDriven(t *testing.T) {
|
|
cases := []struct {
|
|
lang string
|
|
tempAndUnbacked []string // an excluded import folder must say BOTH of these
|
|
promise string // a kept folder must say this
|
|
denial string // and must NOT say this
|
|
}{
|
|
{"hu", []string{"törli innen", "nem készül róla biztonsági mentés"},
|
|
"Biztonsági mentés készül", "nem készül"},
|
|
{"en", []string{"deletes them from here", "is not backed up"},
|
|
"It is backed up", "not backed up"},
|
|
}
|
|
for _, c := range cases {
|
|
msg := bundleMsg(t, c.lang)
|
|
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, msg)
|
|
for _, want := range c.tempAndUnbacked {
|
|
if !strings.Contains(imp, want) {
|
|
t.Errorf("[%s] an excluded import folder must say %q: %q", c.lang, want, imp)
|
|
}
|
|
}
|
|
for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
|
|
lib := consequenceFor(cls, stacks.RoleLibrary, msg)
|
|
if !strings.Contains(lib, c.promise) {
|
|
t.Errorf("[%s] class %q must promise a backup: %q", c.lang, cls, lib)
|
|
}
|
|
if strings.Contains(lib, c.denial) {
|
|
t.Errorf("[%s] class %q must NOT say unbacked: %q", c.lang, cls, lib)
|
|
}
|
|
}
|
|
// An unclassified bind says NOTHING rather than guessing — an unverified backup promise
|
|
// about a customer's files is worse than no sentence. In every language.
|
|
if got := consequenceFor("", stacks.RoleLibrary, msg); got != "" {
|
|
t.Errorf("[%s] an unclassified path must produce no promise, got %q", c.lang, got)
|
|
}
|
|
}
|
|
// And the two languages must not say the same thing — that would mean the bundle fell back and
|
|
// the English household is reading Hungarian, which is the defect this row was opened for.
|
|
hu := consequenceFor(appbackup.ClassMandatory, stacks.RoleLibrary, bundleMsg(t, "hu"))
|
|
en := consequenceFor(appbackup.ClassMandatory, stacks.RoleLibrary, bundleMsg(t, "en"))
|
|
if hu == en {
|
|
t.Errorf("the English consequence line fell back to Hungarian: %q", en)
|
|
}
|
|
}
|
|
|
|
// A card is only built for a DEPLOYED app (Fork-2: deployed-only in the UI).
|
|
func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) {
|
|
s := testServer(t)
|
|
st := &stacks.Stack{Name: "paperless-ngx", Deployed: false,
|
|
Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{
|
|
{Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"},
|
|
}}}
|
|
if got := s.buildDataPathCards(st, "hu"); got != nil {
|
|
t.Errorf("an undeployed app must get no folder card, got %v", got)
|
|
}
|
|
}
|
|
|
|
func excerpt(html, needle string) string {
|
|
i := strings.Index(html, needle)
|
|
if i < 0 {
|
|
return "(needle not found)"
|
|
}
|
|
end := i + 400
|
|
if end > len(html) {
|
|
end = len(html)
|
|
}
|
|
return html[i:end]
|
|
}
|