package upwatch import ( "context" "net/http" "net/http/httptest" "testing" "time" ) // R-1250, measured on demo-hp 2026-10-27: qBittorrent 5.2.4 kept sending at full speed for ~14 s after the hold set its // limit to 1 KiB/s, while the agent read the uplink 5–15 s after the call — every test on request was refused „not // quiet" (four tries in one hour). The controller now asks the agent only after the held apps report their own upload // near zero. // slept counts the drain waits instead of doing them (every test in this package; none waits for real). var slept time.Duration func init() { drainSleep = func(ctx context.Context, d time.Duration) bool { slept += d return ctx.Err() == nil } } // The consequence: the agent is asked only once the held app reported two quiet readings in a row. // RED-PROOF: drop the waitDrained call in SeedLineTestNow → the agent is asked with 0 readings taken → fails. func TestR1250_TheAgentIsAskedOnlyAfterTheHeldAppSlowedDown(t *testing.T) { r := newHoldRig(t) // 5 Mbit/s for three readings (the drain), then quiet; one noisy reading between two quiet ones restarts the count. r.app.rates = []int64{640_000, 640_000, 610_000, 0, 300_000, 0, 0} readsAtCall := -1 r.during = func() { readsAtCall = r.app.rateReads } slept = 0 r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 { t.Fatalf("agent asked %d times, want 1", r.runs) } if readsAtCall != 7 { t.Fatalf("the agent was asked after %d speed readings, want 7 — only after two quiet readings in a row", readsAtCall) } if slept != 6*drainPoll { t.Fatalf("waited %s before asking, want %s", slept, 6*drainPoll) } if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil { t.Fatalf("after the test the app holds %+v (record %+v), want the household's limit back", r.app.limit, r.record()) } } // Every held app must be quiet, not just the first. // RED-PROOF: read only apps[0] in waitDrained → asked while the second still sends → fails. func TestR1250_EveryHeldAppMustSlowDown(t *testing.T) { qb := &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}} tr := &fakeTorrent{name: "transmission", limit: HeldLimit{Enabled: true, KBps: 500}, rates: []int64{400_000, 400_000, 400_000, 0}} runs, trAtCall := 0, -1 svc := &Service{Store: &memStore{}, Torrents: []Holdable{qb, tr}, TorrentRunning: func(string) bool { return true }, LineWaiting: func(context.Context) bool { return true }, LineRun: func(context.Context) (LineRunResult, error) { runs++ trAtCall = tr.rateReads return LineRunResult{OK: true, Mbps: 92}, nil }} svc.SeedLineTestNow(context.Background()) if runs != 1 || trAtCall < 5 { t.Fatalf("asked %d time(s) with %d transmission reading(s), want once and after its two quiet readings (5)", runs, trAtCall) } } // An app that never slows down: the agent is asked anyway after drainMax (it judges the line), and the limit comes back. // RED-PROOF: drop the drainMax check → the loop never ends → the test times out. func TestR1250_AnAppThatNeverSlowsIsAskedAboutAfterTheMaximum(t *testing.T) { r := newHoldRig(t) r.app.rates = []int64{640_000} slept = 0 r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 || slept != drainMax { t.Fatalf("asked %d time(s) after %s, want once after %s", r.runs, slept, drainMax) } if r.app.limit != (HeldLimit{Bytes: 2_499_584}) { t.Fatalf("after the test the app holds %+v, want the household's limit back", r.app.limit) } } // An app whose speed cannot be read: a plain drainFallback wait, then the agent. // RED-PROOF: return at once on a read error → slept 0 → fails. func TestR1250_AnUnreadableSpeedGetsTheFallbackWait(t *testing.T) { r := newHoldRig(t) r.app.failRate = true slept = 0 r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 || slept != drainFallback { t.Fatalf("asked %d time(s) after %s, want once after %s", r.runs, slept, drainFallback) } } // The two real readers, against the field names the apps serve (qBittorrent 5.2.4 transfer/info, Transmission 4.x // session-stats); a missing field is an error, never a quiet 0. func TestR1250_UploadRateReadsTheAppsOwnSpeed(t *testing.T) { qbBody := `{"up_info_speed":655360,"up_info_data":1}` qs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { case "/api/v2/auth/login": http.SetCookie(w, &http.Cookie{Name: "SID", Value: "s"}) w.WriteHeader(204) case "/api/v2/transfer/info": _, _ = w.Write([]byte(qbBody)) default: w.WriteHeader(404) } })) defer qs.Close() qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: qs.URL} if v, err := qb.UploadRate(context.Background()); err != nil || v != 655360 { t.Fatalf("qBittorrent UploadRate = %d, %v; want 655360", v, err) } qbBody = `{"up_info_data":1}` if v, err := qb.UploadRate(context.Background()); err == nil { t.Fatalf("qBittorrent without up_info_speed read %d, want an error", v) } trBody := `{"arguments":{"uploadSpeed":12345,"downloadSpeed":0},"result":"success"}` ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.Header.Get("X-Transmission-Session-Id") == "" { w.Header().Set("X-Transmission-Session-Id", "sid") w.WriteHeader(409) return } _, _ = w.Write([]byte(trBody)) })) defer ts.Close() tr := &Transmission{Box: envBox{"transmission/PASS": "tr-canary"}, URL: ts.URL} if v, err := tr.UploadRate(context.Background()); err != nil || v != 12345 { t.Fatalf("Transmission UploadRate = %d, %v; want 12345", v, err) } trBody = `{"arguments":{"downloadSpeed":0},"result":"success"}` if v, err := tr.UploadRate(context.Background()); err == nil { t.Fatalf("Transmission without uploadSpeed read %d, want an error", v) } }