Files
felhom-controller/controller/internal/mediawire/mediawire_test.go
T

283 lines
10 KiB
Go

package mediawire
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// The fakes answer the calls wire.py measured, with the answer SHAPES it recorded, and keep state — so the second run
// sees what the first one wrote. Every write is counted.
const (
qbPW = "qb-secret-pw-1"
trPW = "tr-secret-pw-2"
sonKey = "sonarr-key-3"
plexT = "plex-token-4"
jfKey = "jf-key-5"
)
type fakeBox struct {
running map[string]bool
dirs []string
}
func (b *fakeBox) Running(c string) bool { return b.running[c] }
func (b *fakeBox) Env(c, n string) (string, error) {
return map[string]string{"qbittorrent/QBT_ADMIN_PASSWORD": qbPW, "transmission/PASS": trPW}[c+"/"+n], nil
}
func (b *fakeBox) ReadFile(c, p string) ([]byte, error) {
if c == "plex" {
return []byte(`<Preferences PlexOnlineToken="` + plexT + `" />`), nil
}
return []byte("<Config><ApiKey>" + sonKey + "</ApiKey></Config>"), nil
}
func (b *fakeBox) JellyfinKey(string) (string, error) { return jfKey, nil }
func (b *fakeBox) EnsureDir(p string) error { b.dirs = append(b.dirs, p); return nil }
func (b *fakeBox) Mounts(c string) map[string]string {
return map[string]string{"/media": "/mnt/felhom-drives/media_a/userdata/media"}
}
type world struct {
mu sync.Mutex
writes []string
// qBittorrent
autoTMM bool
cats map[string]map[string]interface{}
// Sonarr
tags []map[string]interface{}
roots []map[string]interface{}
clients []map[string]interface{}
minFree int
// Plex
plexLocs []string
fsEvent map[string]bool
// Jellyfin
jfLocs []string
}
func (w *world) wrote(s string) { w.mu.Lock(); w.writes = append(w.writes, s); w.mu.Unlock() }
func newWorld() *world {
return &world{cats: map[string]map[string]interface{}{}, plexLocs: []string{"/media/tv"}, fsEvent: map[string]bool{},
jfLocs: []string{"/media/tv"}}
}
func (w *world) server(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
w.mu.Lock()
defer w.mu.Unlock()
j := func(v interface{}) { _ = json.NewEncoder(rw).Encode(v) }
p := r.URL.Path
switch {
// qBittorrent
case p == "/api/v2/auth/login":
_ = r.ParseForm()
if r.Form.Get("password") != qbPW {
rw.WriteHeader(403)
return
}
http.SetCookie(rw, &http.Cookie{Name: "QBT_SID_8080", Value: "sid"})
rw.WriteHeader(204) // qBittorrent 5.x: a good login is 204 No Content (measured on Tester 1, 2026-10-15)
case p == "/api/v2/app/preferences":
j(map[string]interface{}{"auto_tmm_enabled": w.autoTMM})
case p == "/api/v2/app/setPreferences":
w.autoTMM = true
w.writes = append(w.writes, "qb setPreferences")
case p == "/api/v2/torrents/categories":
j(w.cats)
case p == "/api/v2/torrents/createCategory" || p == "/api/v2/torrents/editCategory":
_ = r.ParseForm()
w.cats[r.Form.Get("category")] = map[string]interface{}{"savePath": r.Form.Get("savePath"), "download_path": r.Form.Get("downloadPath")}
w.writes = append(w.writes, "qb "+p)
// Transmission
case p == "/transmission/rpc":
if r.Header.Get("X-Transmission-Session-Id") == "" {
rw.Header().Set("X-Transmission-Session-Id", "s1")
rw.WriteHeader(409)
return
}
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), "session-set") {
w.writes = append(w.writes, "tr session-set")
}
j(map[string]interface{}{"arguments": map[string]interface{}{"incomplete-dir-enabled": false}})
// Sonarr
case p == "/api/v3/tag" && r.Method == "GET":
j(w.tags)
case p == "/api/v3/tag":
var in map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&in)
in["id"] = len(w.tags) + 1
w.tags = append(w.tags, in)
w.writes = append(w.writes, "sonarr tag")
j(in)
case p == "/api/v3/rootfolder" && r.Method == "GET":
j(w.roots)
case p == "/api/v3/rootfolder":
var in map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&in)
w.roots = append(w.roots, in)
w.writes = append(w.writes, "sonarr rootfolder")
j(in)
case p == "/api/v3/downloadclient" && r.Method == "GET":
j(w.clients)
case p == "/api/v3/downloadclient/schema":
j([]map[string]interface{}{
{"implementation": "QBittorrent", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}}},
{"implementation": "Transmission", "fields": []map[string]interface{}{{"name": "host"}, {"name": "password"}, {"name": "tvCategory", "value": "tv-sonarr"}, {"name": "tvDirectory"}}},
})
case p == "/api/v3/downloadclient":
var in map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&in)
for _, f := range in["fields"].([]interface{}) {
fm := f.(map[string]interface{})
if fm["name"] == "tvCategory" && fm["value"] != "" && strings.HasPrefix(in["name"].(string), "Transmission") {
rw.WriteHeader(400) // „Cannot use Category and Directory" (measured)
return
}
}
w.clients = append(w.clients, in)
w.writes = append(w.writes, "sonarr downloadclient "+in["name"].(string))
case p == "/api/v3/config/mediamanagement" && r.Method == "GET":
j(map[string]interface{}{"id": 1, "minimumFreeSpaceWhenImporting": w.minFree})
case strings.HasPrefix(p, "/api/v3/config/mediamanagement/"):
var in map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&in)
w.minFree = int(in["minimumFreeSpaceWhenImporting"].(float64))
w.writes = append(w.writes, "sonarr mediamanagement")
// Plex
case p == "/library/sections" && r.Method == "GET":
if r.Header.Get("X-Plex-Token") != plexT {
rw.WriteHeader(401)
return
}
var locs []map[string]string
for _, l := range w.plexLocs {
locs = append(locs, map[string]string{"path": l})
}
j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Directory": []map[string]interface{}{
{"key": "1", "type": "show", "title": "TV", "agent": "a", "scanner": "s", "language": "hu", "Location": locs}}}})
case p == "/library/sections/1" && r.Method == "PUT":
q, _ := url.ParseQuery(r.URL.RawQuery)
w.plexLocs = q["location"]
w.writes = append(w.writes, "plex section")
case p == "/library/sections/1/refresh":
case p == "/:/prefs" && r.Method == "GET":
var st []map[string]interface{}
for _, id := range []string{"FSEventLibraryUpdatesEnabled", "FSEventLibraryPartialScanEnabled"} {
st = append(st, map[string]interface{}{"id": id, "value": w.fsEvent[id]})
}
j(map[string]interface{}{"MediaContainer": map[string]interface{}{"Setting": st}})
case p == "/:/prefs" && r.Method == "PUT":
for k := range r.URL.Query() {
w.fsEvent[k] = true
}
w.writes = append(w.writes, "plex prefs")
// Jellyfin
case p == "/Library/VirtualFolders":
j([]map[string]interface{}{{"Name": "Sorozatok", "CollectionType": "tvshows", "Locations": w.jfLocs}})
case p == "/Library/VirtualFolders/Paths":
var in map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&in)
w.jfLocs = append(w.jfLocs, in["PathInfo"].(map[string]interface{})["Path"].(string))
w.writes = append(w.writes, "jellyfin path")
case p == "/Library/Refresh":
default:
t.Errorf("unexpected call %s %s", r.Method, p)
rw.WriteHeader(404)
}
}))
}
// TestRun_SecondRunWritesNothing pins `15` §10 „added": one run makes drive B ready in every app; a second run of the
// same state writes NOTHING (K2: the measured second run re-sent equal qBittorrent edits — fixed here by reading first).
// The drive the players' template binds at /media is not added a second time under its same-path name.
//
// COMPANION RED-PROOF (2026-10-15): drop the „already equal" check in qbittorrent() → the second run writes 4
// category edits and this test fails. Restored.
func TestRun_SecondRunWritesNothing(t *testing.T) {
wd := newWorld()
srv := wd.server(t)
defer srv.Close()
var logs strings.Builder
box := &fakeBox{running: map[string]bool{"qbittorrent": true, "transmission": true, "sonarr": true, "plex": true, "jellyfin": true}}
w := &Wirer{Box: box, Logger: log.New(&logs, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
drives := []Drive{{Path: "/mnt/felhom-drives/media_a"}, {Path: "/mnt/felhom-drives/media_b"}}
first := w.Run(drives)
for _, r := range first {
if r.State == "error" || r.State == "household" {
t.Fatalf("first run: %s %s: %s", r.App, r.State, r.Detail)
}
}
n := len(wd.writes)
if n == 0 {
t.Fatal("the first run wrote nothing")
}
want := []string{
"qb setPreferences", "sonarr tag", "sonarr rootfolder", "sonarr downloadclient qBittorrent media-b",
"sonarr downloadclient Transmission media-b", "sonarr mediamanagement", "plex section", "plex prefs", "jellyfin path",
}
all := strings.Join(wd.writes, "|")
for _, x := range want {
if !strings.Contains(all, x) {
t.Errorf("first run did not do %q (writes: %v)", x, wd.writes)
}
}
for _, l := range wd.plexLocs {
if l == "/mnt/felhom-drives/media_a/userdata/media/tv" {
t.Error("drive A is bound at /media for Plex and already in the library as /media/tv — added twice")
}
}
if !containsStr(wd.plexLocs, "/mnt/felhom-drives/media_b/userdata/media/tv") {
t.Errorf("drive B is not in the Plex library: %v", wd.plexLocs)
}
w.Run(drives)
if extra := wd.writes[n:]; len(extra) != 0 {
t.Fatalf("the second run of the same state wrote %d time(s): %v", len(extra), extra)
}
for _, secret := range []string{qbPW, trPW, sonKey, plexT, jfKey} {
if strings.Contains(logs.String(), secret) {
t.Fatalf("a secret reached the log")
}
}
}
// Plex not claimed → a household state, nothing written.
func TestRun_UnclaimedPlexIsAHouseholdStep(t *testing.T) {
wd := newWorld()
srv := wd.server(t)
defer srv.Close()
box := &unclaimedBox{fakeBox{running: map[string]bool{"plex": true}}}
w := &Wirer{Box: box, Logger: log.New(io.Discard, "", 0), HTTP: srv.Client(), URL: func(string, int) string { return srv.URL }}
for _, r := range w.Run([]Drive{{Path: "/mnt/felhom-drives/media_b"}}) {
if r.App == "plex" && r.State != "household" {
t.Fatalf("an unclaimed Plex must be a household step, got %s", r.State)
}
}
if len(wd.writes) != 0 {
t.Fatalf("nothing may be written to an unclaimed Plex: %v", wd.writes)
}
}
type unclaimedBox struct{ fakeBox }
func (b *unclaimedBox) ReadFile(c, p string) ([]byte, error) { return []byte(`<Preferences />`), nil }
func containsStr(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}