v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"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),
|
||||
IsImport: true,
|
||||
FreeSpace: "44.8 GB szabad",
|
||||
}}
|
||||
html := renderBackupPage(t, "app_info", 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_info", 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestConsequenceIsClassDriven(t *testing.T) {
|
||||
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport)
|
||||
if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") {
|
||||
t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp)
|
||||
}
|
||||
for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
|
||||
lib := consequenceFor(cls, stacks.RoleLibrary)
|
||||
if !strings.Contains(lib, "Biztonsági mentés készül") {
|
||||
t.Errorf("class %q must promise a backup: %q", cls, lib)
|
||||
}
|
||||
if strings.Contains(lib, "nem készül") {
|
||||
t.Errorf("class %q must NOT say unbacked: %q", cls, lib)
|
||||
}
|
||||
}
|
||||
// An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a
|
||||
// customer's files is worse than no sentence.
|
||||
if got := consequenceFor("", stacks.RoleLibrary); got != "" {
|
||||
t.Errorf("an unclassified path must produce no promise, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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); 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]
|
||||
}
|
||||
Reference in New Issue
Block a user