package stacks import ( "gopkg.in/yaml.v3" "os" "path/filepath" "strings" "testing" ) // The media_library marker (R-1023, `15` §2/§10). Every test asserts the CONSEQUENCE — what the override file binds — // for a drive set read through the mediaDrivesFn seam. func newMediaStack(t *testing.T, name, compose, felhomYML string, drives []MediaDrive) (*Manager, string) { t.Helper() m, _ := newInstalledManager(t, compose, "deployed: true\nenv: {}\n") dir := filepath.Dir(m.stacks["bookstack"].ComposePath) if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(felhomYML), 0o644); err != nil { t.Fatal(err) } m.mediaDrivesFn = func() []MediaDrive { return drives } m.mediaEnsureDirFn = func(p string) error { return os.MkdirAll(p, 0o755) } return m, dir } func readOverride(t *testing.T, dir string) string { t.Helper() b, err := os.ReadFile(filepath.Join(dir, MediaOverrideFile)) if os.IsNotExist(err) { return "" } if err != nil { t.Fatal(err) } return string(b) } const sonarrLike = "services:\n sonarr:\n image: x\n volumes:\n - sonarr_config:/config\n - ${USERDATA_PATH}/media:${USERDATA_PATH}/media\n" // Guard 1 of `15` §10: bind every PRESENT drive, never an absent or dead one; the drive the template's own line already // binds at that path is not bound twice. // // COMPANION RED-PROOF (2026-10-15): drop the `if !d.Present` skip in mediaOverrideContent → this test fails (the // absent drive C and the dead drive D are bound). Restored. func TestMediaLibrary_BindsOnlyPresentDrives(t *testing.T) { root := t.TempDir() A, B, C, D := filepath.Join(root, "media_a"), filepath.Join(root, "media_b"), filepath.Join(root, "media_c"), filepath.Join(root, "media_d") drives := []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}, {Path: C, Why: "not mounted"}, {Path: D, Why: "dead (shutdown)"}} m, dir := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", drives) env := []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")} m.renderMediaOverride(dir, env) got := readOverride(t, dir) bm := filepath.Join(B, "userdata", "media") if !strings.Contains(got, " - "+bm+":"+bm+"\n") { t.Fatalf("drive B (present) is not bound at the same path:\n%s", got) } for _, absent := range []string{C, D} { if strings.Contains(got, absent) { t.Fatalf("an absent/dead drive %s is bound — the app would not start (K3-4) or would see an empty folder:\n%s", absent, got) } } if strings.Contains(got, filepath.Join(A, "userdata", "media")+":") { t.Fatalf("drive A is already bound by the template's own line; binding it twice breaks compose:\n%s", got) } if _, err := os.Stat(filepath.Join(C, "userdata", "media")); err == nil { t.Fatal("a folder was made on an ABSENT drive — that is an empty folder on the root filesystem") } } // A download client binds only its own folder per drive (the D9 limit); a player binds read-only. func TestMediaLibrary_FolderAndReadOnly(t *testing.T) { root := t.TempDir() A, B := filepath.Join(root, "a"), filepath.Join(root, "b") drives := []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}} qb := "services:\n qbittorrent:\n image: x\n volumes:\n - ${USERDATA_PATH}/media/torrents:${USERDATA_PATH}/media/torrents\n - ${USERDATA_PATH}/media/torrents:/downloads\n" m, dir := newMediaStack(t, "qbittorrent", qb, "media_library:\n folder: torrents\n", drives) m.renderMediaOverride(dir, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")}) got := readOverride(t, dir) bt := filepath.Join(B, "userdata", "media", "torrents") if !strings.Contains(got, " - "+bt+":"+bt+"\n") || strings.Contains(got, filepath.Join(B, "userdata", "media")+":") { t.Fatalf("qBittorrent must bind ONLY media/torrents of drive B:\n%s", got) } plex := "services:\n plex:\n image: x\n volumes:\n - plex_config:/config\n - ${USERDATA_PATH}/media:/media:ro\n" m2, dir2 := newMediaStack(t, "plex", plex, "media_library:\n read_only: true\n", drives) m2.renderMediaOverride(dir2, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")}) got = readOverride(t, dir2) for _, d := range []string{A, B} { // the template binds A at /media, so A's same-path bind is new too p := filepath.Join(d, "userdata", "media") if !strings.Contains(got, " - "+p+":"+p+":ro\n") { t.Fatalf("the player must bind %s read-only at the same path:\n%s", p, got) } } } // No marker → no file, and a file that is not ours is never touched. The second render of the same state changes // nothing (no restart for nothing). func TestMediaLibrary_NoMarkerNoFileAndIdempotent(t *testing.T) { root := t.TempDir() drives := []MediaDrive{{Path: filepath.Join(root, "a"), Present: true}, {Path: filepath.Join(root, "b"), Present: true}} m, dir := newMediaStack(t, "sonarr", sonarrLike, "display_name: x\n", drives) env := []string{"USERDATA_PATH=" + filepath.Join(root, "a", "userdata")} if _, changed := m.renderMediaOverride(dir, env); changed || readOverride(t, dir) != "" { t.Fatal("a template without the marker must get no override file") } m2, dir2 := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", drives) if _, changed := m2.renderMediaOverride(dir2, env); !changed { t.Fatal("the first render must write the file") } if _, changed := m2.renderMediaOverride(dir2, env); changed { t.Fatal("a second render of the same drives must change nothing") } foreign := "services: {}\n" if err := os.WriteFile(filepath.Join(dir2, MediaOverrideFile), []byte(foreign), 0o644); err != nil { t.Fatal(err) } m2.renderMediaOverride(dir2, env) if readOverride(t, dir2) != foreign { t.Fatal("an override file that is not ours must never be overwritten") } } // `15` §7/§8: the per-drive binds are in no capture set and no export — the classifier and the export read only // docker-compose.yml, never the override. COMPANION RED-PROOF: point ParseComposeClassifiableBinds at the override file // → this test fails. Restored. func TestMediaLibrary_OverrideIsInNoCaptureSet(t *testing.T) { root := t.TempDir() A, B := filepath.Join(root, "a"), filepath.Join(root, "b") m, dir := newMediaStack(t, "sonarr", sonarrLike, "media_library: {}\n", []MediaDrive{{Path: A, Present: true}, {Path: B, Present: true}}) m.renderMediaOverride(dir, []string{"USERDATA_PATH=" + filepath.Join(A, "userdata")}) if readOverride(t, dir) == "" { t.Fatal("setup: the override must exist") } compose := filepath.Join(dir, "docker-compose.yml") for _, b := range ParseComposeClassifiableBinds(compose) { if strings.Contains(b.RelPath, B) || strings.Contains(b.RelPath, "media_b") { t.Fatalf("a per-drive media bind reached the backup classifier: %+v", b) } } for _, p := range ExportDataMounts(compose, A, A) { if strings.HasPrefix(p, B) { t.Fatalf("a per-drive media bind reached the .fab export: %s", p) } } for _, p := range ParseComposeHDDMounts(compose, B) { t.Fatalf("a removal on drive B would touch %s — the override must be invisible to removal", p) } } // R-1031: the wizard does not ask a media app for a drive; an app without the marker is still asked. // COMPANION RED-PROOF (2026-10-15): drop the MediaLibrary skip in UserFacingFields → this test fails. Restored. func TestR1031_MediaAppIsNotAskedForADrive(t *testing.T) { fields := []DeployField{{EnvVar: "SUBDOMAIN", Type: "subdomain"}, {EnvVar: "HDD_PATH", Type: "path", Required: true}} media := Metadata{DeployFields: fields, MediaLibrary: &MediaLibrarySpec{}} for _, f := range media.UserFacingFields() { if f.EnvVar == "HDD_PATH" { t.Fatal("a media app's wizard asks for a drive (R-1031)") } } plain := Metadata{DeployFields: fields} asked := false for _, f := range plain.UserFacingFields() { asked = asked || f.EnvVar == "HDD_PATH" } if !asked { t.Fatal("an app without the marker must still be asked for its drive") } } // R-1028/R-1045: an `after_start` field (Plex's claim code) is not in this controller's wizard and may be empty here; // the same template stays REQUIRED for an older controller (it ignores the key). COMPANION RED-PROOF: drop the // AfterStart skip in UserFacingFields → this test fails. Restored. func TestAfterStartField_NotAskedAtInstall(t *testing.T) { m := Metadata{DeployFields: []DeployField{{EnvVar: "PLEX_CLAIM", Type: "text", Required: true, AfterStart: true}, {EnvVar: "SUBDOMAIN", Type: "subdomain"}}} for _, f := range m.UserFacingFields() { if f.EnvVar == "PLEX_CLAIM" { t.Fatal("the claim code is asked at install — the 4-minute race is back (R-1028)") } } var meta Metadata if err := yaml.Unmarshal([]byte("deploy_fields:\n - env_var: PLEX_CLAIM\n type: text\n required: true\n after_start: true\n"), &meta); err != nil || !meta.DeployFields[0].AfterStart || !meta.DeployFields[0].Required { t.Fatalf("the template shape must parse as required + after_start: %+v %v", meta.DeployFields, err) } }