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

190 lines
8.8 KiB
Go

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)
}
}