test: add comprehensive test suite for engine, agent and cmd packages

- Refactor download.go and stream.go with downloadDeps/streamDeps structs
  for dependency injection, enabling unit testing without real I/O
- download_test.go: 15 tests — input validation, mock downloaders, method
  selection, cobra Args, deadlock detection
- stream_test.go: input validation, noOpen flag, engine error handling
- client_test.go: context cancellation, timeout, full Sync roundtrip,
  watch-progress and HTTP error unwrapping
- sync_test.go: TriggerSync on watching transition, adjustInterval
- torrent_test.go: TorrentDownloader lifecycle without network
- stream_server_test.go: HTTP server lifecycle, SetFile/ClearFile,
  concurrent requests, Shutdown releases port, content-type
- manager_integration_test.go: full pipeline — success, torrent→debrid
  fallback, all-fail, multi-concurrent, ForceStart, OnTaskDone,
  recent-finished drain, cancel mid-download, organize
- usenet_test.go: Cancel/Pause race regression test (run with -race)
- daemon_test.go: isAllowedStreamPath table tests
- CI: split coverage gate to engine+agent only (50% threshold); cmd
  coverage still reported but not gated (interactive UI commands)
- lefthook: add pre-push hook with go test -race -count=1 -timeout=120s
This commit is contained in:
Deivid Soto 2026-04-08 23:36:00 +02:00
parent b14ab98580
commit 78c16c295e
13 changed files with 2421 additions and 10 deletions

View file

@ -327,6 +327,186 @@ func TestSyncClient_Run_CancelStopsLoop(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// runWakeListener tests
// ---------------------------------------------------------------------------
func TestRunWakeListener_TriggersSyncOnWake(t *testing.T) {
// Server responds immediately with wake=true on the first call
var wakeCallCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
wakeCallCount.Add(1)
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
return
}
// sync endpoint — just respond OK
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
go sc.runWakeListener(ctx)
// Give the listener time to receive the wake and call TriggerSync
time.Sleep(200 * time.Millisecond)
cancel()
if wakeCallCount.Load() < 1 {
t.Error("expected at least one wake request")
}
// TriggerSync puts something in the buffered channel
select {
case <-sc.SyncNow:
// good — listener triggered a sync
default:
// channel may have been drained by Run (not running here) — check count
// The important thing is that wakeCallCount > 0 (request was made)
}
}
func TestRunWakeListener_ReconnectsAfterTimeout(t *testing.T) {
// Server returns wake=false (timeout) then wake=true on reconnect
callCount := atomic.Int32{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(SyncResponse{})
return
}
n := callCount.Add(1)
if n == 1 {
// First call: timeout
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
} else {
// Second call: wake
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
}
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go sc.runWakeListener(ctx)
// Wait for at least 2 wake calls (reconnect after timeout)
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
if callCount.Load() >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
if callCount.Load() < 2 {
t.Errorf("expected at least 2 wake requests (reconnect after timeout), got %d", callCount.Load())
}
}
func TestRunWakeListener_RetriesAfterNetworkError(t *testing.T) {
// Server that refuses connections initially, then starts accepting
callCount := atomic.Int32{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(SyncResponse{})
return
}
callCount.Add(1)
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
}))
defer srv.Close()
// Use a bad URL first, then switch — we can't easily switch URL, so
// test with a server that always errors (closed connection) via a custom transport
badClient := NewClient("http://127.0.0.1:1", "test-key", "unarr-test")
cfg := DaemonConfig{AgentID: "test-agent", Version: "1.0.0", DownloadDir: "/tmp"}
state := NewLocalState()
sc := NewSyncClient(badClient, cfg, state)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Should not panic — just log errors and retry
done := make(chan struct{})
go func() {
sc.runWakeListener(ctx)
close(done)
}()
select {
case <-done:
// Good — listener exited when ctx was cancelled
case <-time.After(2 * time.Second):
t.Error("runWakeListener did not exit after context cancellation")
}
}
func TestRunWakeListener_StopsOnContextCancel(t *testing.T) {
// Server blocks until client disconnects
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
<-r.Context().Done()
return
}
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
sc.runWakeListener(ctx)
close(done)
}()
// Let it connect and block
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
// Good
case <-time.After(2 * time.Second):
t.Error("runWakeListener did not stop when context was cancelled")
}
}
func TestRunWakeListener_DoesNotTriggerSyncOnTimeout(t *testing.T) {
// Server always returns wake=false — SyncNow channel should stay empty
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
return
}
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
go sc.runWakeListener(ctx)
<-ctx.Done()
// SyncNow should be empty (no wake triggered)
select {
case <-sc.SyncNow:
t.Error("expected no sync trigger on timeout response")
default:
// Good
}
}
func TestSyncClient_Run_ImmediateSyncOnTrigger(t *testing.T) {
var syncCount atomic.Int32