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(``), nil } return []byte("" + sonKey + ""), 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(``), nil } func containsStr(xs []string, s string) bool { for _, x := range xs { if x == s { return true } } return false }