package appbackup import ( "strings" "testing" ) // classOf finds the resolved class+origin for a (root, relpath) in a ClassifiedBind slice. func classOf(cbs []ClassifiedBind, root BindRoot, rel string) (BindClass, ClassOrigin, bool) { for _, c := range cbs { if c.Root == root && c.RelPath == rel { return c.Class, c.Origin, true } } return "", "", false } // --- Group A: classifier --- // TestClassify_ImmichShape is Scenario A: explicit classes resolve, and an EXPLICIT entry beats the // :ro reader-default (media/photos is :ro but ruled optional). Companion RP-2: making the ro-default // override explicit entries forces media/photos to excluded and fails the optional assertion. func TestClassify_ImmichShape(t *testing.T) { binds := []ComposeBind{ {Root: RootHDD, RelPath: "appdata/immich", ReadOnly: false}, {Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, // :ro external library } spec := &BackupSpec{ HDD: []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}}, Userdata: []BindSpec{{Path: "media/photos", Class: ClassOptional}}, } cbs, has := ClassifyBinds(spec, binds) if !has { t.Fatal("hasClassification should be true with a spec present") } if cls, org, ok := classOf(cbs, RootHDD, "appdata/immich"); !ok || cls != ClassMandatory || org != OriginExplicit { t.Errorf("appdata/immich = %v/%v, want mandatory/explicit", cls, org) } // The crux: an explicit optional beats the :ro default_ro that would otherwise force excluded. if cls, org, ok := classOf(cbs, RootUserdata, "media/photos"); !ok || cls != ClassOptional || org != OriginExplicit { t.Errorf("media/photos (:ro, explicit optional) = %v/%v, want optional/explicit (explicit beats ro-default)", cls, org) } } // TestClassify_TwoLevelDefault is Scenario B: with a block PRESENT, an unlisted writable bind // defaults mandatory (capture, the C6B-F1 direction) and an unlisted :ro bind defaults excluded // (reader rule). Companion RP-3: flipping the unlisted-writable default to excluded fails the // mandatory assertion. func TestClassify_TwoLevelDefault(t *testing.T) { binds := []ComposeBind{ {Root: RootHDD, RelPath: "appdata/app", ReadOnly: false}, // listed {Root: RootUserdata, RelPath: "data/extra", ReadOnly: false}, // UNLISTED writable {Root: RootUserdata, RelPath: "media/ro", ReadOnly: true}, // UNLISTED :ro } spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/app", Class: ClassMandatory}}} cbs, has := ClassifyBinds(spec, binds) if !has { t.Fatal("hasClassification should be true") } if cls, org, _ := classOf(cbs, RootUserdata, "data/extra"); cls != ClassMandatory || org != OriginDefaultWritable { t.Errorf("unlisted writable = %v/%v, want mandatory/default_writable (capture direction)", cls, org) } if cls, org, _ := classOf(cbs, RootUserdata, "media/ro"); cls != ClassExcluded || org != OriginDefaultRO { t.Errorf("unlisted :ro = %v/%v, want excluded/default_ro (reader rule)", cls, org) } } // TestClassify_NilSpecLegacy is Scenario C: a nil spec → every bind is legacy with no class, and // hasClassification=false. This is the inertness gate at the classifier level. func TestClassify_NilSpecLegacy(t *testing.T) { binds := []ComposeBind{{Root: RootUserdata, RelPath: "media", ReadOnly: true}} cbs, has := ClassifyBinds(nil, binds) if has { t.Error("nil spec must report hasClassification=false") } if len(cbs) != 1 || cbs[0].Origin != OriginLegacy || cbs[0].Class != "" { t.Errorf("nil-spec bind = %+v, want origin=legacy, empty class", cbs[0]) } } // TestClassify_BareRootFallsToDefault: a bare-root bind (RelPath "") can't be matched by any explicit // entry (empty paths are invalid), so it falls to the ro/writable default. func TestClassify_BareRootFallsToDefault(t *testing.T) { spec := &BackupSpec{Userdata: []BindSpec{{Path: "media/x", Class: ClassOptional}}} cbs, _ := ClassifyBinds(spec, []ComposeBind{ {Root: RootUserdata, RelPath: "", ReadOnly: false}, // bare ${USERDATA_PATH} {Root: RootHDD, RelPath: "", ReadOnly: true}, // bare ${HDD_PATH} :ro }) if cls, org, _ := classOf(cbs, RootUserdata, ""); cls != ClassMandatory || org != OriginDefaultWritable { t.Errorf("bare writable root = %v/%v, want mandatory/default_writable", cls, org) } if cls, org, _ := classOf(cbs, RootHDD, ""); cls != ClassExcluded || org != OriginDefaultRO { t.Errorf("bare :ro root = %v/%v, want excluded/default_ro", cls, org) } } // TestClassify_SameRelPathBothRoots: userdata/x and hdd/x are DISTINCT binds — Root is part of // identity, so an explicit hdd entry must not classify the userdata bind. func TestClassify_SameRelPathBothRoots(t *testing.T) { binds := []ComposeBind{ {Root: RootUserdata, RelPath: "shared", ReadOnly: false}, {Root: RootHDD, RelPath: "shared", ReadOnly: false}, } spec := &BackupSpec{HDD: []BindSpec{{Path: "shared", Class: ClassExcluded}}} cbs, _ := ClassifyBinds(spec, binds) if cls, org, _ := classOf(cbs, RootHDD, "shared"); cls != ClassExcluded || org != OriginExplicit { t.Errorf("hdd/shared = %v/%v, want excluded/explicit", cls, org) } if cls, org, _ := classOf(cbs, RootUserdata, "shared"); cls != ClassMandatory || org != OriginDefaultWritable { t.Errorf("userdata/shared = %v/%v, want mandatory/default_writable (hdd entry must NOT match it)", cls, org) } } // --- Group B: validation (Scenario D) — every defect rejects the WHOLE block; error names the entry --- func TestValidateBackupSpec_Defects(t *testing.T) { // The compose binds the valid entries reference (so only the seeded defect is the failure). binds := []ComposeBind{ {Root: RootUserdata, RelPath: "media/tv"}, {Root: RootHDD, RelPath: "appdata/x"}, } cases := []struct { name string spec *BackupSpec wantFrag string // substring the error must contain (the offending entry / rule) }{ {"unknown class", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: "keepit"}}}, "invalid class"}, {"empty class (typoed key)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: ""}}}, "invalid class"}, {"empty path", &BackupSpec{HDD: []BindSpec{{Path: "", Class: ClassMandatory}}}, "empty path"}, {"absolute path", &BackupSpec{HDD: []BindSpec{{Path: "/etc/x", Class: ClassMandatory}}}, "absolute"}, {"dotdot path", &BackupSpec{HDD: []BindSpec{{Path: "../escape", Class: ClassMandatory}}}, "escapes"}, {"backslash path", &BackupSpec{HDD: []BindSpec{{Path: "appdata\\x", Class: ClassMandatory}}}, "backslash"}, {"non-clean path", &BackupSpec{HDD: []BindSpec{{Path: "appdata/./x", Class: ClassMandatory}}}, "non-clean"}, {"duplicate path", &BackupSpec{HDD: []BindSpec{ {Path: "appdata/x", Class: ClassMandatory}, {Path: "appdata/x", Class: ClassExcluded}, }}, "duplicate"}, {"no matching bind (typo)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tvv", Class: ClassExcluded}}}, "matches no compose bind"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := ValidateBackupSpec(tc.spec, binds) if err == nil { t.Fatalf("expected rejection, got nil") } if !strings.Contains(err.Error(), tc.wantFrag) { t.Errorf("error %q must contain %q", err.Error(), tc.wantFrag) } }) } } // TestValidateBackupSpec_ValidAndNil: a clean block validates, and a nil spec is vacuously valid. func TestValidateBackupSpec_ValidAndNil(t *testing.T) { binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/x"}, {Root: RootUserdata, RelPath: "media/tv"}} spec := &BackupSpec{ HDD: []BindSpec{{Path: "appdata/x", Class: ClassMandatory}}, Userdata: []BindSpec{{Path: "media/tv", Class: ClassExcluded}}, } if err := ValidateBackupSpec(spec, binds); err != nil { t.Errorf("clean block should validate: %v", err) } if err := ValidateBackupSpec(nil, binds); err != nil { t.Errorf("nil spec must be vacuously valid: %v", err) } }