//go:build linux || freebsd // +build linux freebsd package api import ( "strings" "testing" "time" ) func TestVersion(t *testing.T) { v := Version() if v == "" { t.Error("Version() returned an empty string") } } func TestVersionFormat(t *testing.T) { v := Version() if !strings.Contains(v, ".") { t.Errorf("Version() = %q; expected semver-like format", v) } } func TestNewClient(t *testing.T) { client := NewClient() if client == nil { t.Fatal("NewClient() returned nil") } defer client.Close() } func TestNewClientWithOptions(t *testing.T) { client := NewClient(WithVerbose(true), WithUserAgent("Test/1.0")) if client == nil { t.Fatal("NewClient() returned nil") } defer client.Close() } func TestDownloadRequestValidation(t *testing.T) { client := NewClient() defer client.Close() if _, err := client.Download(nil); err == nil { t.Error("expected error for nil request") } if _, err := client.Download(&DownloadRequest{}); err == nil { t.Error("expected error for empty URL") } } func TestUploadRequestValidation(t *testing.T) { client := NewClient() defer client.Close() if _, err := client.Upload(nil); err == nil { t.Error("expected error for nil request") } if _, err := client.Upload(&UploadRequest{URL: "http://example.com"}); err == nil { t.Error("expected error for missing FilePath") } if _, err := client.Upload(&UploadRequest{FilePath: "/tmp/test"}); err == nil { t.Error("expected error for missing URL") } } func TestDownloadResultFields(t *testing.T) { result := &DownloadResult{ BytesDownloaded: 1024, Protocol: "HTTP/2", } if result.BytesDownloaded != 1024 { t.Errorf("BytesDownloaded = %d", result.BytesDownloaded) } } func TestUploadResultFields(t *testing.T) { result := &UploadResult{ BytesUploaded: 512, } if result.BytesUploaded != 512 { t.Errorf("BytesUploaded = %d", result.BytesUploaded) } } func TestDownloadConvenience(t *testing.T) { _, err := Download("", "") if err == nil { t.Error("expected error for empty URL") } } func TestUploadConvenience(t *testing.T) { _, err := Upload("", "", "") if err == nil { t.Error("expected error for empty fields") } } // TestRequestContextTimeout is a regression guard for the BACKLOG entry // "`pkg/api` Client timeout is lifetime, not per-request". The previous // implementation created a single context.WithTimeout in NewClient, so a // Client that lived longer than cfg.timeout could no longer serve any // request. The fixed implementation uses a cancellable background context // for the Client scope and derives a per-request child with the timeout // applied at call time. func TestRequestContextTimeout(t *testing.T) { client := NewClient(WithTimeout(5 * time.Second)) defer client.Close() reqCtx, cancel := client.requestContext() defer cancel() deadline, ok := reqCtx.Deadline() if !ok { t.Fatal("per-request context has no deadline; want cfg.timeout") } remaining := time.Until(deadline) // Allow generous slack to avoid flakes on a busy CI runner, but // enforce that the deadline is at most the configured timeout and // has not already passed. if remaining <= 0 { t.Errorf("per-request deadline already passed: %v remaining", remaining) } if remaining > 5*time.Second { t.Errorf("per-request deadline = %v from now; want <= 5s (cfg.timeout)", remaining) } } // TestRequestContextNoTimeout verifies that a Client constructed with // WithTimeout(0) yields a per-request context without a deadline. This // preserves the "no timeout" semantics that callers relied on before the // per-request refactor, when the only way to disable the lifetime cap // was to never call Download at all. func TestRequestContextNoTimeout(t *testing.T) { client := NewClient(WithTimeout(0)) defer client.Close() reqCtx, cancel := client.requestContext() defer cancel() if _, ok := reqCtx.Deadline(); ok { t.Error("per-request context has a deadline; want none when cfg.timeout == 0") } } // TestRequestContextCancelledByClientClose verifies that cancelling the // Client scope propagates to per-request children. A long-running // Download must abort when Close() is called, even if its own deadline // has not been reached. func TestRequestContextCancelledByClientClose(t *testing.T) { client := NewClient(WithTimeout(time.Hour)) defer client.Close() reqCtx, cancel := client.requestContext() defer cancel() // Sanity: nothing is cancelled yet. if err := reqCtx.Err(); err != nil { t.Fatalf("reqCtx unexpectedly cancelled before Close: %v", err) } client.Close() // Close cancels the client scope, which propagates to the child. select { case <-reqCtx.Done(): case <-time.After(2 * time.Second): t.Fatal("per-request context not cancelled after Client.Close()") } if err := reqCtx.Err(); err == nil { t.Error("reqCtx.Err() = nil after Client.Close(); want non-nil") } } // TestClientScopeHasNoDeadline is a regression guard for the original // bug. The previous implementation wrapped context.Background() in // WithTimeout at NewClient time, so a Client that lived past cfg.timeout // had a context that was already past its deadline — and any // context.WithTimeout child would be expired immediately. func TestClientScopeHasNoDeadline(t *testing.T) { client := NewClient(WithTimeout(50 * time.Millisecond)) defer client.Close() if _, ok := client.ctx.Deadline(); ok { t.Error("client-scope context has a deadline; lifetime cap would re-introduce the bug") } } // TestRequestContextCancelledByClientClose above already proves that // per-request contexts are derived from the client scope: cancelling the // client cancels the per-request child. A direct parent-chain walk is not // possible from outside the context package because the `parent` field // is unexported.