2d4d43203f
A1: AutoDiscoverStoragePaths no longer bails on a non-empty registry; registers only deployed-app paths missing from the registry. Never mutates/removes existing entries, never re-adds or reactivates a path present in ANY state (incl. Decommissioned), never flips IsDefault. A2: InferStorageLabel maps base==felhom-data namespace dir to 'Belső SSD (rendszer)' to disambiguate the internal system volume. Table-driven tests incl. a companion that fails without the skip-by-presence guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
7.6 KiB
Go
230 lines
7.6 KiB
Go
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
|
|
}
|