package upwatch import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" ) // fakeTorrent is a torrent app's limit in memory. failWrite fails the next write AFTER storing it (a write that // took while its read-back failed); failRead fails reads. type fakeTorrent struct { limit HeldLimit writes []HeldLimit failWrite bool failRead bool } func (f *fakeTorrent) Stack() string { return "qbittorrent" } func (f *fakeTorrent) HoldValue() HeldLimit { return HeldLimit{Bytes: 1024} } func (f *fakeTorrent) ReadLimit(context.Context) (HeldLimit, error) { if f.failRead { return HeldLimit{}, errors.New("connection refused") } return f.limit, nil } func (f *fakeTorrent) WriteLimit(_ context.Context, l HeldLimit) (HeldLimit, error) { f.limit = l f.writes = append(f.writes, l) if f.failWrite { f.failWrite = false return HeldLimit{}, errors.New("read-back: EOF") } return l, nil } type holdRig struct { svc *Service app *fakeTorrent store *memStore runs int waiting bool running bool result LineRunResult runErr error during func() // runs inside the agent's test } // newHoldRig: qBittorrent running with the household's own 2.5 MB/s limit, the agent waiting for quiet. func newHoldRig(t *testing.T) *holdRig { r := &holdRig{app: &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}}, store: &memStore{}, waiting: true, running: true, result: LineRunResult{OK: true, Mbps: 93.5}} r.svc = &Service{Store: r.store, Torrents: []Holdable{r.app}, TorrentRunning: func(string) bool { return r.running }, LineWaiting: func(context.Context) bool { return r.waiting }, LineRun: func(context.Context) (LineRunResult, error) { r.runs++ if r.during != nil { r.during() } return r.result, r.runErr }} return r } func (r *holdRig) record() *LineHold { return decode(r.store.GetUpwatch()).Hold } // R-1250, the brief's three cases: the old limit — the household's own — comes back exactly after a measured test, // after a failed one, and after a refused one; during the test the app holds the test value and the record exists. // RED-PROOF: drop the release after LineRun → every case keeps 1024; write the record after the hold → the in-test // check sees no record. func TestHold_PutsTheHouseholdsLimitBackExactly(t *testing.T) { for _, tc := range []struct { name string result LineRunResult err error }{ {"measured", LineRunResult{OK: true, Mbps: 93.5}, nil}, {"failed test", LineRunResult{Error: "dial tcp 10.77.0.1:5201: i/o timeout"}, nil}, {"refused", LineRunResult{Refused: "not-quiet", Error: "the uplink sent 16.0 Mbit/s while held"}, nil}, {"agent unreachable", LineRunResult{}, errors.New("agentapi: POST /host/line-test/run: context deadline exceeded")}, } { t.Run(tc.name, func(t *testing.T) { r := newHoldRig(t) r.result, r.runErr = tc.result, tc.err r.during = func() { if r.app.limit != (HeldLimit{Bytes: 1024}) { t.Fatalf("during the test the app holds %+v, want 1 KiB/s", r.app.limit) } h := r.record() if h == nil || h.Prev != (HeldLimit{Bytes: 2_499_584}) || h.Held != (HeldLimit{Bytes: 1024}) { t.Fatalf("during the test the record is %+v — a restart now could not put the limit back", h) } } r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 { t.Fatalf("agent asked %d times, want 1", r.runs) } if r.app.limit != (HeldLimit{Bytes: 2_499_584}) { t.Fatalf("after the test the app holds %+v, want the household's 2499584 back", r.app.limit) } if r.record() != nil { t.Fatal("the hold record was not cleared") } }) } } // „no limit" (0) is a limit too: it comes back as 0, never as the test value or the 50 % default. func TestHold_PutsBackNoLimit(t *testing.T) { r := newHoldRig(t) r.app.limit = HeldLimit{} r.svc.SeedLineTestNow(context.Background()) if r.app.limit != (HeldLimit{}) || len(r.app.writes) != 2 { t.Fatalf("limit %+v after writes %+v, want 0 after hold+release", r.app.limit, r.app.writes) } } // A controller restart in the middle: the record is on disk and the app still holds the test value. The start-up // check puts the old limit back. // RED-PROOF: make RecoverHold return true without releasing → the app stays at 1024. func TestHold_StartupPutsBackALimitLeftHeld(t *testing.T) { r := newHoldRig(t) // What a killed controller leaves: the record written, the app at the test value (no defer runs on SIGKILL). _ = 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}}}) }) r.app.limit = HeldLimit{Bytes: 1024} restarted := &Service{Store: r.store, Torrents: []Holdable{r.app}, TorrentRunning: func(string) bool { return true }} if !restarted.RecoverHold(context.Background()) { t.Fatal("RecoverHold left a record") } if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil { t.Fatalf("after the restart the app holds %+v (record %+v), want the household's limit back", r.app.limit, r.record()) } } // A panic inside the agent call still puts the limit back before it travels on. func TestHold_APanicInTheTestStillPutsTheLimitBack(t *testing.T) { r := newHoldRig(t) r.during = func() { panic("agentapi bug") } func() { defer func() { _ = recover() }() r.svc.SeedLineTestNow(context.Background()) }() if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil { t.Fatalf("after a panic: limit %+v record %+v", r.app.limit, r.record()) } } // The household changed the limit while it was held: theirs stays. func TestHold_AHouseholdChangeDuringTheTestStays(t *testing.T) { r := newHoldRig(t) r.during = func() { r.app.limit = HeldLimit{Bytes: 5_120_000} } r.svc.SeedLineTestNow(context.Background()) if r.app.limit != (HeldLimit{Bytes: 5_120_000}) || r.record() != nil { t.Fatalf("limit %+v record %+v, want the household's new 5120000 kept and the record cleared", r.app.limit, r.record()) } } // If anything fails, the limit is put back FIRST: a hold write that took but failed its read-back is undone, and // the agent is never asked. func TestHold_AFailedHoldIsUndoneAndNoTestRuns(t *testing.T) { r := newHoldRig(t) r.app.failWrite = true r.svc.SeedLineTestNow(context.Background()) if r.runs != 0 { t.Fatal("the agent was asked after the hold failed") } if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil { t.Fatalf("limit %+v record %+v, want the old limit back and no record", r.app.limit, r.record()) } } // The app cannot be reached when the limit should go back: the record stays, and the next tick puts it back. While a // record exists no new hold starts. func TestHold_AnUnreachableAppKeepsTheRecordUntilItIsBack(t *testing.T) { r := newHoldRig(t) r.during = func() { r.app.failRead = true } r.svc.SeedLineTestNow(context.Background()) if r.record() == nil || r.app.limit != (HeldLimit{Bytes: 1024}) { t.Fatalf("limit %+v record %+v, want the record kept while the app is unreachable", r.app.limit, r.record()) } r.svc.SeedLineTestNow(context.Background()) if r.runs != 1 { t.Fatal("a second hold started while the first was not put back") } r.app.failRead = false r.running = false if r.svc.RecoverHold(context.Background()) || r.app.limit != (HeldLimit{Bytes: 1024}) { t.Fatal("a stopped app cannot be put back; the record must wait") } r.running, r.during = true, nil r.svc.SeedLineTestNow(context.Background()) // recovers first; the agent is still waiting, so a new test follows if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil || r.runs != 2 { t.Fatalf("limit %+v record %+v runs %d", r.app.limit, r.record(), r.runs) } } // No hold without a reason: the agent not waiting, or no torrent app running. func TestHold_OnlyWhenTheAgentWaitsAndAnAppRuns(t *testing.T) { r := newHoldRig(t) r.waiting = false r.svc.SeedLineTestNow(context.Background()) r.waiting, r.running = true, false r.svc.SeedLineTestNow(context.Background()) if r.runs != 0 || len(r.app.writes) != 0 { t.Fatalf("runs %d writes %+v, want none", r.runs, r.app.writes) } } // qBittorrent's 50 % rule must not run on a held limit: it would read 1 KiB/s as the household's change and stop for // good (QBLimitState.Household). // RED-PROOF: remove the Hold check in QBLimitNow → Household turns true. func TestHold_QBLimitRuleSkipsAHeldLimit(t *testing.T) { logins, expire, limit := 0, false, int64(1024) srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit) defer srv.Close() st := &memStore{} _ = st.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{QBLimit: &QBLimitState{BoxSetBytes: 5_654 * 1024, LineMbps: 92.6}, Hold: &LineHold{App: "qbittorrent", Prev: HeldLimit{Bytes: 5_654 * 1024}, Held: HeldLimit{Bytes: 1024}}}) }) svc := &Service{Store: st, QB: &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}, QBShare: 0.5} svc.QBLimitNow(context.Background()) if q := decode(st.GetUpwatch()).QBLimit; q == nil || q.Household || limit != 1024 { t.Fatalf("QBLimitNow on a held limit: record %+v, limit %d — the box's limit became the household's", q, limit) } } // The two apps' real APIs: qBittorrent's global limit, Transmission's session-get/session-set with the session-id // handshake. The test value is never zero (zero is „no limit"). func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) { logins, expire, limit := 0, false, int64(2_499_584) srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit) defer srv.Close() qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL} if l, err := qb.ReadLimit(context.Background()); err != nil || l.Bytes != 2_499_584 { t.Fatalf("qBittorrent read %+v %v", l, err) } if l, err := qb.WriteLimit(context.Background(), qb.HoldValue()); err != nil || l != (HeldLimit{Bytes: 1024}) || limit != 1024 { t.Fatalf("qBittorrent hold: %+v %v, stored %d", l, err, limit) } tr := struct { up int64 enabled bool }{up: 500, enabled: false} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Transmission-Session-Id") != "sid-1" { w.Header().Set("X-Transmission-Session-Id", "sid-1") w.WriteHeader(409) return } if u, p, ok := r.BasicAuth(); !ok || u != "admin" || p != "tr-canary" { w.WriteHeader(401) return } b, _ := io.ReadAll(r.Body) var req struct { Method string `json:"method"` Arguments map[string]any `json:"arguments"` } _ = json.Unmarshal(b, &req) switch req.Method { case "session-get": fmt.Fprintf(w, `{"arguments":{"speed-limit-up":%d,"speed-limit-up-enabled":%t},"result":"success"}`, tr.up, tr.enabled) case "session-set": if v, ok := req.Arguments["speed-limit-up"].(float64); ok { tr.up = int64(v) } if v, ok := req.Arguments["speed-limit-up-enabled"].(bool); ok { tr.enabled = v } fmt.Fprint(w, `{"arguments":{},"result":"success"}`) default: fmt.Fprint(w, `{"result":"method name not recognized"}`) } })) defer ts.Close() tm := &Transmission{Box: envBox{"transmission/PASS": "tr-canary"}, URL: ts.URL} prev, err := tm.ReadLimit(context.Background()) if err != nil || prev != (HeldLimit{KBps: 500}) { t.Fatalf("Transmission read %+v %v, want 500 kB/s switched off", prev, err) } if l, err := tm.WriteLimit(context.Background(), tm.HoldValue()); err != nil || l != (HeldLimit{Enabled: true, KBps: 1}) { t.Fatalf("Transmission hold: %+v %v", l, err) } if l, err := tm.WriteLimit(context.Background(), prev); err != nil || l != prev || tr.enabled || tr.up != 500 { t.Fatalf("Transmission put back: %+v %v (server %d %v), want 500 switched off", l, err, tr.up, tr.enabled) } if _, err := tm.rpc(context.Background(), `{"method":"no-such"}`); err == nil || !strings.Contains(err.Error(), "not recognized") { t.Fatalf("an RPC result other than success must be an error, got %v", err) } }