Files
felhom-controller/controller/internal/appbackup/appdatadirnames_test.go
T
admin 68f0e0cf5c F-S2 + F-S3: compose-derived appdata dir resolution (v0.131.0)
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.

- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
  got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
  WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
  cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
  previously copied nothing and left an empty media dir (scope="all" was saved by
  the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
  migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
  false; corrected in CHANGELOG + main.go export-adapter comment.

+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 17:45:53 +02:00

105 lines
3.2 KiB
Go

package appbackup
import (
"path/filepath"
"reflect"
"testing"
)
// fp joins the elements under an HDD path with OS separators — mounts in the ParseComposeHDDMounts
// shape are already filepath.Clean'd, so tests build them the same way.
func fp(elems ...string) string { return filepath.Join(elems...) }
// TestAppDataDirNames is the pure derivation table (Group A). Every case asserts the RESOLVED name
// list, never mere absence of error. Companion RP-1: a resolver that ignores mounts and returns
// []string{stackName} fails the paperless, two-name, and dedupe cases.
func TestAppDataDirNames(t *testing.T) {
const hdd = "/mnt/felhom-usb"
cases := []struct {
name string
stack string
mounts []string
want []string
}{
{
// paperless shape: stack "paperless-ngx", dir "paperless" (F-S2/F-S3 core).
name: "paperless mismatch",
stack: "paperless-ngx",
mounts: []string{
fp(hdd, "appdata", "paperless", "media"),
fp(hdd, "appdata", "paperless", "export"),
},
want: []string{"paperless"}, // media+export dedupe to one name
},
{
// match shape: dir name == stack name (immich/nextcloud/romm).
name: "matching name",
stack: "nextcloud",
mounts: []string{fp(hdd, "appdata", "nextcloud")},
want: []string{"nextcloud"},
},
{
// two DISTINCT names → both, sorted (no catalog app does this today).
name: "two distinct names sorted",
stack: "weird",
mounts: []string{
fp(hdd, "appdata", "zebra", "x"),
fp(hdd, "appdata", "alpha", "y"),
},
want: []string{"alpha", "zebra"},
},
{
// non-appdata HDD binds + a foreign-drive mount are filtered → fallback.
name: "non-appdata and foreign filtered",
stack: "romm",
mounts: []string{
fp(hdd, "roms"), // under HDD but not appdata/
fp("/mnt/other-drive", "appdata", "ghost"), // foreign drive — wrong prefix
},
want: []string{"romm"},
},
{
// whole-appdata-root bind (no name derivable) → ignored → fallback.
name: "whole appdata root bind",
stack: "root-binder",
mounts: []string{fp(hdd, "appdata")},
want: []string{"root-binder"},
},
{
name: "empty mounts fallback",
stack: "vaultwarden",
mounts: nil,
want: []string{"vaultwarden"},
},
{
// unclean paths still resolve (Clean applied both sides).
name: "unclean path",
stack: "paperless-ngx",
mounts: []string{hdd + "/appdata/paperless/../paperless/media"},
want: []string{"paperless"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := AppDataDirNames(hdd, tc.stack, tc.mounts)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("AppDataDirNames(%q, %q, %v) = %v, want %v", hdd, tc.stack, tc.mounts, got, tc.want)
}
})
}
}
// TestAppDataBindsPresent pins the WARN predicate: true only when a mount sits under appdata/.
func TestAppDataBindsPresent(t *testing.T) {
const hdd = "/mnt/felhom-usb"
if !AppDataBindsPresent(hdd, []string{fp(hdd, "appdata", "paperless", "media")}) {
t.Error("declared appdata bind should report present")
}
if AppDataBindsPresent(hdd, []string{fp(hdd, "roms")}) {
t.Error("non-appdata bind should NOT report present")
}
if AppDataBindsPresent(hdd, nil) {
t.Error("no mounts should NOT report present")
}
}