Files
felhom-controller/controller/internal/stacks/classify_wiring_test.go
T

241 lines
7.6 KiB
Go

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)
}
}
})
}
}
// R-1252: the second-drive copy must tell a REJECTED block from an absent one (a rejected one keeps every copy and
// alarms; an absent one is quiet). Through the REAL LoadMetadata path. RED-PROOF: drop the BackupRejected assignment
// in LoadMetadata → the rejected fixture reads "" and this fails.
func TestR1252_BackupBlockRejectedNamesTheReason(t *testing.T) {
compose := `services:
app:
volumes:
- ${USERDATA_PATH}/media/audiobooks:/audiobooks
`
bad := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/nope\n class: mandatory\n", compose)
if why := bad.BackupBlockRejected("app"); why == "" {
t.Fatal("a block whose path matches no compose bind must be reported as rejected, with its reason")
}
good := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/audiobooks\n class: mandatory\n", compose)
if why := good.BackupBlockRejected("app"); why != "" {
t.Fatalf("a valid block is not rejected, got %q", why)
}
none := newClassifyManager(t, "display_name: App\n", compose)
if why := none.BackupBlockRejected("app"); why != "" {
t.Fatalf("an absent block is not rejected, got %q", why)
}
}