Files
felhom-controller/controller/internal/upwatch/linehold_r1261_test.go
T

191 lines
7.7 KiB
Go

package upwatch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// fakeQBPrefs is qBittorrent 5.2.4's limit API as measured on 2026-10-25 (felhom.eu
// `audits/night-2026-10-25/r1261-qbittorrent-api-measured.txt`): transfer/uploadLimit and setUploadLimit act on the
// slot of the CURRENT speed mode (setUploadLimit rounds to whole KiB); app/preferences shows up_limit and alt_up_limit
// in B/s; app/setPreferences writes either slot exactly, in either mode.
type fakeQBPrefs struct {
up, alt int64
altMode bool
}
func (f *fakeQBPrefs) server(t *testing.T, pw string) *httptest.Server {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/auth/login" {
r.ParseForm()
if r.Header.Get("Referer") != srv.URL || r.Form.Get("password") != pw {
w.WriteHeader(401)
return
}
http.SetCookie(w, &http.Cookie{Name: "QBT_SID_8080", Value: "s1"})
w.WriteHeader(204)
return
}
active := &f.up
if f.altMode {
active = &f.alt
}
switch r.URL.Path {
case "/api/v2/transfer/speedLimitsMode":
if f.altMode {
fmt.Fprint(w, "1")
} else {
fmt.Fprint(w, "0")
}
case "/api/v2/transfer/toggleSpeedLimitsMode":
f.altMode = !f.altMode
case "/api/v2/transfer/uploadLimit":
fmt.Fprint(w, *active)
case "/api/v2/transfer/setUploadLimit":
r.ParseForm()
var v int64
fmt.Sscan(r.Form.Get("limit"), &v)
*active = v / 1024 * 1024
case "/api/v2/app/preferences":
json.NewEncoder(w).Encode(map[string]any{"up_limit": f.up, "alt_up_limit": f.alt, "scheduler_enabled": false})
case "/api/v2/app/setPreferences":
r.ParseForm()
var m map[string]int64
if err := json.Unmarshal([]byte(r.Form.Get("json")), &m); err != nil {
w.WriteHeader(400)
return
}
if v, ok := m["up_limit"]; ok {
f.up = v
}
if v, ok := m["alt_up_limit"]; ok {
f.alt = v
}
default:
w.WriteHeader(404)
}
}))
return srv
}
// R-1261 edge 1: two seeders behind one uplink. Holding only the first left the second seeding, so the agent refused
// the test „not quiet" every 15 minutes and it was never counted.
// RED-PROOF: hold only the first running app (the 0.343.0 loop's `break`) → the second is never held → fails.
func TestR1261_EverySeederIsHeldAndEveryLimitComesBack(t *testing.T) {
qb := &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}}
tr := &fakeTorrent{name: "transmission", limit: HeldLimit{Enabled: true, KBps: 500}}
store := &memStore{}
runs := 0
svc := &Service{Store: store, Torrents: []Holdable{qb, tr},
TorrentRunning: func(string) bool { return true },
LineWaiting: func(context.Context) bool { return true },
LineRun: func(context.Context) (LineRunResult, error) {
runs++
if qb.limit != (HeldLimit{Bytes: 1024}) || tr.limit != (HeldLimit{Bytes: 1024}) {
t.Fatalf("during the test qbittorrent=%+v transmission=%+v — every seeder must be at the test value", qb.limit, tr.limit)
}
if n := len(heldList(decode(store.GetUpwatch()))); n != 2 {
t.Fatalf("during the test %d record(s), want 2 — a restart now could not put both back", n)
}
return LineRunResult{OK: true, Mbps: 92}, nil
}}
svc.SeedLineTestNow(context.Background())
if runs != 1 {
t.Fatalf("agent asked %d times, want 1", runs)
}
if qb.limit != (HeldLimit{Bytes: 2_499_584}) || tr.limit != (HeldLimit{Enabled: true, KBps: 500}) {
t.Fatalf("after the test qbittorrent=%+v transmission=%+v, want both limits back", qb.limit, tr.limit)
}
if n := len(heldList(decode(store.GetUpwatch()))); n != 0 {
t.Fatalf("%d record(s) left after the test", n)
}
}
// R-1261 edge 2: an app removed while held. The record was kept „until it runs" — forever — and qBittorrent's 50 %
// rule never ran again. The consequence asserted: the record goes, and the next hold can run.
// RED-PROOF: drop the TorrentInstalled check in RecoverHold → the record stays, no test runs → fails.
func TestR1261_ARecordOfAnAppRemovedWhileHeldIsDropped(t *testing.T) {
r := newHoldRig(t)
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}})
})
r.running = false
installed := false
r.svc.TorrentInstalled = func(string) bool { return installed }
if !r.svc.RecoverHold(context.Background()) || r.record() != nil {
t.Fatalf("the record of a removed app must be dropped, got %+v", r.record())
}
// Still installed but stopped: the record is kept for when it runs again (the old rule, unchanged).
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}})
})
installed = true
if r.svc.RecoverHold(context.Background()) || r.record() == nil {
t.Fatal("a stopped but installed app keeps its record")
}
// The app is reinstalled and runs: the hold runs again (the next weekly test is no longer blocked).
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{}) })
r.running = true
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 {
t.Fatalf("after the record is gone the line test runs again; runs=%d", r.runs)
}
}
// R-1261 edge 3: the household switches qBittorrent to its alternative speed during the test. transfer/uploadLimit
// then reads the ALTERNATIVE slot, the release took it for „the household's change", and the normal slot kept 1 KiB/s.
// Now the hold names its slot and the release reads and writes that slot.
// RED-PROOF: read the current mode's slot in ReadLimitLike (the old behaviour) → up_limit stays 1024 → fails.
func TestR1261_ASpeedModeSwitchDuringTheTestNeverLeavesTheTestValue(t *testing.T) {
for _, startAlt := range []bool{false, true} {
t.Run(fmt.Sprintf("hold in alt mode=%v", startAlt), func(t *testing.T) {
fq := &fakeQBPrefs{up: 2_499_584, alt: 3_072_000, altMode: startAlt}
srv := fq.server(t, "pw-canary")
defer srv.Close()
qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}
store := &memStore{}
svc := &Service{Store: store, Torrents: []Holdable{qb},
TorrentRunning: func(string) bool { return true },
LineWaiting: func(context.Context) bool { return true },
LineRun: func(context.Context) (LineRunResult, error) {
held := fq.up
if startAlt {
held = fq.alt
}
if held != 1024 {
t.Fatalf("during the test the active slot holds %d, want 1024", held)
}
fq.altMode = !fq.altMode // the household presses the turtle
return LineRunResult{OK: true, Mbps: 92}, nil
}}
svc.SeedLineTestNow(context.Background())
if fq.up != 2_499_584 || fq.alt != 3_072_000 {
t.Fatalf("after the test up_limit=%d alt_up_limit=%d, want 2499584 and 3072000 — never the 1 KiB/s test value", fq.up, fq.alt)
}
if n := len(heldList(decode(store.GetUpwatch()))); n != 0 {
t.Fatalf("%d record(s) left", n)
}
})
}
}
// A record that controller 0.343.0 left (the single `line_test_hold`) is honoured and cleared.
func TestR1261_ARecordThe0343ControllerLeftIsPutBack(t *testing.T) {
r := newHoldRig(t)
r.app.limit = HeldLimit{Bytes: 1024}
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
return json.Marshal(State{Hold: &LineHold{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}})
})
if !r.svc.RecoverHold(context.Background()) || r.app.limit != (HeldLimit{Bytes: 2_499_584}) {
t.Fatalf("limit %+v, want the 0.343.0 record's limit back", r.app.limit)
}
if st := decode(r.store.GetUpwatch()); st.Hold != nil || len(st.Holds) != 0 {
t.Fatalf("the old record must be cleared, got %+v", st)
}
}