Files
felhom-controller/controller/internal/stacks/classify_wiring_test.go
T
admin 0649f9a3e6 Backup classification: schema + parser + pure classifier (INERT, v0.132.0)
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.

- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
  ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
  writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
  ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
  binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
  catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
  (delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
  now so Task 3 consumes a tested seam.

INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 18:46:32 +02:00

218 lines
6.4 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)
}
}
})
}
}