package settings import ( "io" "log" "path/filepath" "reflect" "testing" ) // newTestSettings returns a Settings backed by a writable temp file so save() works. func newTestSettings(t *testing.T, paths []StoragePath) *Settings { t.Helper() logger := log.New(io.Discard, "", 0) s, err := Load(filepath.Join(t.TempDir(), "settings.json"), logger) if err != nil { t.Fatalf("Load: %v", err) } s.StoragePaths = paths return s } // TestAutoDiscoverStoragePaths_Additive covers the additive registration behaviour and // its invariants: existing entries are never mutated, decommissioned entries are never // reactivated, and IsDefault is never promoted over an existing default. func TestAutoDiscoverStoragePaths_Additive(t *testing.T) { logger := log.New(io.Discard, "", 0) tests := []struct { name string existing []StoragePath discovered []string fallback string wantPaths []string // expected Path set after discovery (order-insensitive) wantNewPath string // a path expected to be newly added (may be "") // assertions run against the resulting registry check func(t *testing.T, s *Settings, before []StoragePath) }{ { name: "non-empty registry registers only the missing deployed path", existing: []StoragePath{ {Path: "/mnt/felhom-usb", Label: "Külső tárhely (felhom-usb)", IsDefault: true, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"}, }, discovered: []string{"/mnt/felhom-usb", "/mnt/hdd_2"}, // existing + a new one wantPaths: []string{"/mnt/felhom-usb", "/mnt/hdd_2"}, wantNewPath: "/mnt/hdd_2", check: func(t *testing.T, s *Settings, before []StoragePath) { // pre-existing entry must be byte-identical (incl. IsDefault unchanged) got := findPath(s, "/mnt/felhom-usb") if got == nil { t.Fatalf("pre-existing path vanished") } if !reflect.DeepEqual(*got, before[0]) { t.Errorf("pre-existing entry mutated:\n before=%+v\n after =%+v", before[0], *got) } // the newly-added path must NOT have stolen default nw := findPath(s, "/mnt/hdd_2") if nw == nil { t.Fatalf("new path /mnt/hdd_2 not registered") } if nw.IsDefault { t.Errorf("new path promoted to default over existing default") } if !nw.Schedulable { t.Errorf("new path should be schedulable") } }, }, { name: "registry with no default lets first new path become default", existing: []StoragePath{ {Path: "/mnt/felhom-usb", Label: "x", IsDefault: false, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"}, }, discovered: []string{"/mnt/hdd_2", "/mnt/hdd_3"}, wantPaths: []string{"/mnt/felhom-usb", "/mnt/hdd_2", "/mnt/hdd_3"}, wantNewPath: "/mnt/hdd_2", check: func(t *testing.T, s *Settings, before []StoragePath) { d2 := findPath(s, "/mnt/hdd_2") d3 := findPath(s, "/mnt/hdd_3") if d2 == nil || d3 == nil { t.Fatalf("new paths not registered") } if !d2.IsDefault { t.Errorf("first new path should become default when registry has none") } if d3.IsDefault { t.Errorf("only one new path may become default") } }, }, { name: "empty registry behaves like the original (first becomes default)", existing: nil, discovered: []string{"/mnt/felhom-usb", "/mnt/hdd_2"}, wantPaths: []string{"/mnt/felhom-usb", "/mnt/hdd_2"}, wantNewPath: "/mnt/felhom-usb", check: func(t *testing.T, s *Settings, before []StoragePath) { first := findPath(s, "/mnt/felhom-usb") if first == nil || !first.IsDefault { t.Errorf("first discovered path should be default in an empty registry") } }, }, { name: "fallback path registered when missing", existing: []StoragePath{ {Path: "/mnt/felhom-usb", Label: "x", IsDefault: true, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"}, }, discovered: nil, fallback: "/mnt/legacy_hdd", wantPaths: []string{"/mnt/felhom-usb", "/mnt/legacy_hdd"}, wantNewPath: "/mnt/legacy_hdd", check: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := newTestSettings(t, cloneStoragePaths(tc.existing)) before := cloneStoragePaths(tc.existing) s.AutoDiscoverStoragePaths(tc.discovered, tc.fallback, logger) // Normalize with filepath.Clean so comparisons hold on both Linux (the deploy // target) and Windows (the dev machine), where Clean uses backslashes. gotSet := map[string]bool{} for _, sp := range s.StoragePaths { gotSet[filepath.Clean(sp.Path)] = true } if len(gotSet) != len(tc.wantPaths) { t.Fatalf("path count = %d, want %d (%v)", len(gotSet), len(tc.wantPaths), pathList(s)) } for _, w := range tc.wantPaths { if !gotSet[filepath.Clean(w)] { t.Errorf("missing expected path %q (have %v)", w, pathList(s)) } } if tc.wantNewPath != "" && findPath(s, tc.wantNewPath) == nil { t.Errorf("expected new path %q to be registered", tc.wantNewPath) } if tc.check != nil { tc.check(t, s, before) } }) } } // TestAutoDiscoverStoragePaths_DecommissionedNotReactivated is the companion guard: a // decommissioned path that is still referenced by a deployed app must NOT be re-added or // reactivated. This test FAILS if the skip-by-presence guard is removed. func TestAutoDiscoverStoragePaths_DecommissionedNotReactivated(t *testing.T) { logger := log.New(io.Discard, "", 0) existing := []StoragePath{ {Path: "/mnt/old_hdd", Label: "Külső tárhely (old_hdd)", IsDefault: false, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z", Decommissioned: true, DecommissionedAt: "2026-02-01T00:00:00Z", MigratedTo: "/mnt/felhom-usb"}, {Path: "/mnt/felhom-usb", Label: "x", IsDefault: true, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"}, } s := newTestSettings(t, cloneStoragePaths(existing)) before := cloneStoragePaths(existing) // A deployed app still points at the decommissioned drive. s.AutoDiscoverStoragePaths([]string{"/mnt/old_hdd", "/mnt/felhom-usb"}, "", logger) if len(s.StoragePaths) != 2 { t.Fatalf("path count changed: got %d want 2 (%v)", len(s.StoragePaths), pathList(s)) } got := findPath(s, "/mnt/old_hdd") if got == nil { t.Fatalf("decommissioned path vanished") } if !got.Decommissioned { t.Errorf("decommissioned path was REACTIVATED (skip-by-presence guard missing)") } if !reflect.DeepEqual(*got, before[0]) { t.Errorf("decommissioned entry mutated:\n before=%+v\n after =%+v", before[0], *got) } } func TestInferStorageLabel(t *testing.T) { tests := []struct { path string want string }{ {"/mnt/sys_drive/felhom-data", "Belső SSD (rendszer)"}, {"/var/lib/felhom/felhom-data", "Belső SSD (rendszer)"}, {"/mnt/felhom-usb", "Tárhely (felhom-usb)"}, // "felhom-usb" doesn't start with "usb" {"/mnt/hdd_1", "Külső tárhely (hdd_1)"}, {"/mnt/ssd_data", "Külső tárhely (ssd_data)"}, {"/srv/backups", "Tárhely (backups)"}, } for _, tc := range tests { if got := InferStorageLabel(tc.path); got != tc.want { t.Errorf("InferStorageLabel(%q) = %q, want %q", tc.path, got, tc.want) } } } // --- helpers --- func cloneStoragePaths(in []StoragePath) []StoragePath { if in == nil { return nil } out := make([]StoragePath, len(in)) copy(out, in) for i := range out { if in[i].StoppedStacks != nil { out[i].StoppedStacks = append([]string(nil), in[i].StoppedStacks...) } } return out } func findPath(s *Settings, path string) *StoragePath { want := filepath.Clean(path) for i := range s.StoragePaths { if filepath.Clean(s.StoragePaths[i].Path) == want { return &s.StoragePaths[i] } } return nil } func pathList(s *Settings) []string { var out []string for _, sp := range s.StoragePaths { out = append(out, sp.Path) } return out }