package stacks import ( "log" "os" "path/filepath" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // newClassifyManager registers a single stack "app" whose .felhom.yml + docker-compose.yml are // written to a temp dir, so Manager.ClassifiedBinds runs the REAL LoadMetadata → validate → classify // path end-to-end (NO seams) — the F-S3 lesson: the wiring is where typos hide. func newClassifyManager(t *testing.T, felhomYML, compose string) *Manager { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(felhomYML), 0o644); err != nil { t.Fatal(err) } composePath := filepath.Join(dir, "docker-compose.yml") if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil { t.Fatal(err) } return &Manager{ cfg: &config.Config{}, logger: log.New(os.Stderr, "", 0), mu: sync.RWMutex{}, stacks: map[string]*Stack{"app": {Name: "app", ComposePath: composePath}}, } } func classSet(cbs []appbackup.ClassifiedBind) map[string]appbackup.BindClass { m := map[string]appbackup.BindClass{} for _, c := range cbs { m[string(c.Root)+"/"+c.RelPath] = c.Class } return m } // --- Group D: end-to-end wiring (NO seams) --- // TestClassifiedBinds_ValidFixture (Group D, immich shape): a clean block resolves through the REAL // LoadMetadata path to the expected explicit classes. func TestClassifiedBinds_ValidFixture(t *testing.T) { felhom := `display_name: Immich backup: hdd: - path: appdata/immich class: mandatory userdata: - path: media/photos class: optional ` compose := `services: immich: volumes: - ${HDD_PATH}/appdata/immich:/usr/src/app/upload - ${USERDATA_PATH}/media/photos:/external/photos:ro ` m := newClassifyManager(t, felhom, compose) cbs, has := m.ClassifiedBinds("app") if !has { t.Fatal("valid block must classify (hasClassification=true)") } got := classSet(cbs) if got["hdd/appdata/immich"] != appbackup.ClassMandatory { t.Errorf("appdata/immich = %q, want mandatory", got["hdd/appdata/immich"]) } if got["userdata/media/photos"] != appbackup.ClassOptional { t.Errorf("media/photos = %q, want optional (explicit beats :ro default)", got["userdata/media/photos"]) } } // TestClassifiedBinds_RejectedFixture (Group D + RP-4): a block whose entry matches no compose bind // is rejected by the REAL LoadMetadata choke point → the app degrades to legacy (hasClassification // false, no class semantics). Companion RP-4: if LoadMetadata accepts meta.Backup WITHOUT validating, // this returns hasClassification=true and the assertion FAILS. func TestClassifiedBinds_RejectedFixture(t *testing.T) { felhom := `display_name: Sonarr backup: userdata: - path: media/tvv class: excluded ` compose := `services: sonarr: volumes: - ${USERDATA_PATH}/media/tv:/tv ` m := newClassifyManager(t, felhom, compose) cbs, has := m.ClassifiedBinds("app") if has { t.Fatal("a block with an unmatched path must be REJECTED → legacy (hasClassification=false)") } // The real bind is still returned, but as legacy (no class) — never silently shifted to a default. for _, c := range cbs { if c.Origin != appbackup.OriginLegacy || c.Class != "" { t.Errorf("rejected-block bind = %+v, want origin=legacy, empty class", c) } } } // TestClassifiedBinds_NoBlockLegacy: an app with no backup block classifies as legacy (inertness). func TestClassifiedBinds_NoBlockLegacy(t *testing.T) { felhom := `display_name: Navidrome` compose := `services: nav: volumes: - ${USERDATA_PATH}/media/music:/music:ro ` m := newClassifyManager(t, felhom, compose) if _, has := m.ClassifiedBinds("app"); has { t.Error("no backup block → hasClassification must be false (legacy)") } } // --- Group E: catalog fidelity (≥3 real blocks) --- // TestCatalogBlocks_Fidelity pins the exact class sets of representative Part-4 blocks (immich, // paperless-ngx, sonarr) against representative composes — the same content committed to the catalog. // The full 13-app proof is the Part-4 cross-check against the live catalog clone (§13 + REPORT). func TestCatalogBlocks_Fidelity(t *testing.T) { cases := []struct { name string felhom string compose string want map[string]appbackup.BindClass }{ { name: "immich", felhom: `backup: hdd: - path: appdata/immich class: mandatory userdata: - path: media/photos class: optional `, compose: `services: immich: volumes: - ${HDD_PATH}/appdata/immich:/usr/src/app/upload - ${USERDATA_PATH}/media/photos:/external/photos:ro `, want: map[string]appbackup.BindClass{ "hdd/appdata/immich": appbackup.ClassMandatory, "userdata/media/photos": appbackup.ClassOptional, }, }, { name: "paperless-ngx", felhom: `backup: hdd: - path: appdata/paperless/media class: mandatory - path: appdata/paperless/export class: excluded userdata: - path: import/paperless class: excluded `, compose: `services: webserver: volumes: - ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media - ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export - ${USERDATA_PATH}/import/paperless:/usr/src/paperless/consume `, want: map[string]appbackup.BindClass{ "hdd/appdata/paperless/media": appbackup.ClassMandatory, "hdd/appdata/paperless/export": appbackup.ClassExcluded, "userdata/import/paperless": appbackup.ClassExcluded, }, }, { name: "sonarr", felhom: `backup: userdata: - path: media/tv class: excluded - path: downloads class: excluded `, compose: `services: sonarr: volumes: - ${USERDATA_PATH}/media/tv:/tv - ${USERDATA_PATH}/downloads:/downloads `, want: map[string]appbackup.BindClass{ "userdata/media/tv": appbackup.ClassExcluded, "userdata/downloads": appbackup.ClassExcluded, }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { m := newClassifyManager(t, tc.felhom, tc.compose) cbs, has := m.ClassifiedBinds("app") if !has { t.Fatalf("%s block must classify clean", tc.name) } got := classSet(cbs) if len(got) != len(tc.want) { t.Errorf("%s: got %d binds, want %d: %v", tc.name, len(got), len(tc.want), got) } for k, want := range tc.want { if got[k] != want { t.Errorf("%s: %s = %q, want %q", tc.name, k, got[k], want) } } }) } }