191 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|