78c894234c
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
172 lines
7.7 KiB
Go
172 lines
7.7 KiB
Go
package stacks
|
|
|
|
import (
|
|
"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")
|
|
}
|
|
}
|