package appexport import ( "io" "log" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) func fabWrite(t *testing.T, root, rel, content string) { t.Helper() p := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0644); err != nil { t.Fatal(err) } } // Scenario C at the export level: the userdata.tar keeps the mandatory subtree and NOT the siblings, // and the manifest still lists the single `userdata` basename (v1 unchanged). The bundle-level anchor // for the SQ6 fix (mirrors the §13 before/after). func TestFabExport_ExcludeScopedUserdataTar(t *testing.T) { drive := t.TempDir() // hddPath fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK") fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE") fabWrite(t, drive, "userdata/music/s.flac", "SONG") ud := appbackup.UserdataDir(filepath.Clean(drive)) prov := &fabProv{ rtProvider: &rtProvider{}, hddPath: drive, has: true, binds: []appbackup.ClassifiedBind{mUD("media/books"), xUD("media/movies")}, mounts: []string{ud}, } e := NewExporter(prov, log.New(io.Discard, "", 0), "test") dataDir := t.TempDir() man := &Manifest{} if err := e.exportHDDData(ExportRequest{StackName: "calibre-web"}, dataDir, man); err != nil { t.Fatalf("exportHDDData: %v", err) } entries := tarEntries(t, filepath.Join(dataDir, "hdd", "userdata.tar")) if !containsSuffix(entries, "media/books/a.epub") { t.Errorf("mandatory media/books missing from userdata.tar: %v", entries) } for _, sib := range []string{"media/movies/big.mkv", "media/movies", "music/s.flac", "music"} { if containsSuffix(entries, sib) { t.Errorf("sibling %q must NOT ride along (SQ6): %v", sib, entries) } } // v1 manifest: the single `userdata` basename, unchanged. if len(man.HDDSubdirs) != 1 || man.HDDSubdirs[0] != "userdata" { t.Errorf("manifest must list the single v1 `userdata` basename, got %v", man.HDDSubdirs) } } // Scenario A at the export level: a legacy (no-block) app tars the FULL userdata root (every sibling) // — byte-identical to v0.130.0 (the SQ5 safety net). func TestFabExport_LegacyFullRoot(t *testing.T) { drive := t.TempDir() fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK") fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE") ud := appbackup.UserdataDir(filepath.Clean(drive)) prov := &fabProv{rtProvider: &rtProvider{}, hddPath: drive, has: false, mounts: []string{ud}} e := NewExporter(prov, log.New(io.Discard, "", 0), "test") dataDir := t.TempDir() man := &Manifest{} if err := e.exportHDDData(ExportRequest{StackName: "sonarr"}, dataDir, man); err != nil { t.Fatalf("exportHDDData: %v", err) } entries := tarEntries(t, filepath.Join(dataDir, "hdd", "userdata.tar")) for _, want := range []string{"media/books/a.epub", "media/movies/big.mkv"} { if !containsSuffix(entries, want) { t.Errorf("legacy app must capture the FULL root — %q missing: %v", want, entries) } } } // Scenario B at the export level: an all-excluded app produces NO userdata.tar (root tar skipped). func TestFabExport_AllExcludedNoUserdataTar(t *testing.T) { drive := t.TempDir() fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE") ud := appbackup.UserdataDir(filepath.Clean(drive)) prov := &fabProv{ rtProvider: &rtProvider{}, hddPath: drive, has: true, binds: []appbackup.ClassifiedBind{xUD("media/movies")}, mounts: []string{ud}, } e := NewExporter(prov, log.New(io.Discard, "", 0), "test") dataDir := t.TempDir() man := &Manifest{} if err := e.exportHDDData(ExportRequest{StackName: "radarr"}, dataDir, man); err != nil { t.Fatalf("exportHDDData: %v", err) } if _, err := os.Stat(filepath.Join(dataDir, "hdd", "userdata.tar")); !os.IsNotExist(err) { t.Errorf("all-excluded app must produce NO userdata.tar (Scenario B), stat err=%v", err) } if len(man.HDDSubdirs) != 0 { t.Errorf("no userdata leg → no manifest subdir, got %v", man.HDDSubdirs) } } // §7-F: EstimateExport populates the class split for a classified app (both web estimate pipelines // call this shared function, so both surface it). du returns 0 on the Windows test host, so this // asserts the STRUCTURE (HasClassification + item keys), not byte values. func TestEstimateExport_ClassifiedSplit(t *testing.T) { drive := t.TempDir() stackDir := t.TempDir() os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644) prov := &fabProv{ rtProvider: &rtProvider{stackDir: stackDir, deployed: true}, hddPath: drive, has: true, binds: []appbackup.ClassifiedBind{mUD("media/books"), oUD("media/comics"), xUD("media/movies")}, } e := NewExporter(prov, log.New(io.Discard, "", 0), "test") est, err := e.EstimateExport("calibre-web", drive) if err != nil { t.Fatalf("EstimateExport: %v", err) } if !est.HasClassification { t.Fatal("classified app estimate must carry HasClassification") } if len(est.MandatoryItems) != 1 || est.MandatoryItems[0].Key != "userdata/media/books" { t.Errorf("MandatoryItems = %+v", est.MandatoryItems) } if len(est.OptionalItems) != 1 || est.OptionalItems[0].Key != "userdata/media/comics" { t.Errorf("OptionalItems = %+v", est.OptionalItems) } if len(est.ExcludedItems) != 1 || est.ExcludedItems[0].Key != "userdata/media/movies" { t.Errorf("ExcludedItems = %+v", est.ExcludedItems) } }