a09cab124a
gates / gates (push) Successful in 41s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
283 lines
10 KiB
Go
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
|
|
}
|