From 08d8f3fb6566579e2337c31a76c5d315366e2e66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:58:49 +0000 Subject: [PATCH 1/5] feat: add standalone TorBoxClient constructors, IDisposable, and AuthHandler string constructor - ITorBoxClient extends IDisposable - TorBoxClientOptions: MainApiVersionedUrl and RelayApiVersionedUrl are now public - AuthHandler: add string apiKey constructor for standalone mode - TorBoxClient: 3 public standalone constructors + DI constructor with [ActivatorUtilitiesConstructor] - TorBoxClient: IDisposable implementation (disposes owned HttpClients in standalone mode) - TorBoxServiceCollectionExtensions: simplified RegisterCore using AddScoped() Agent-Logs-Url: https://github.com/devRael1/TorBoxSDK/sessions/872eff32-b524-402e-8001-6cb89ea05db2 Co-authored-by: devRael1 <91017912+devRael1@users.noreply.github.com> --- .../TorBoxServiceCollectionExtensions.cs | 9 +- src/TorBoxSDK/Http/Handlers/AuthHandler.cs | 37 ++++-- src/TorBoxSDK/ITorBoxClient.cs | 2 +- src/TorBoxSDK/TorBoxClient.cs | 123 +++++++++++++++++- src/TorBoxSDK/TorBoxClientOptions.cs | 12 +- 5 files changed, 160 insertions(+), 23 deletions(-) diff --git a/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs b/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs index c80d608..4eb63c2 100644 --- a/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs +++ b/src/TorBoxSDK/DependencyInjection/TorBoxServiceCollectionExtensions.cs @@ -98,11 +98,8 @@ private static void RegisterCore(IServiceCollection services) .AddHttpMessageHandler(); // --- Only ITorBoxClient / TorBoxClient is registered in the DI container --- - services.AddScoped(sp => - { - IHttpClientFactory httpClientFactory = sp.GetRequiredService(); - IOptions options = sp.GetRequiredService>(); - return new TorBoxClient(httpClientFactory, options); - }); + // The container resolves the (IHttpClientFactory, IOptions) constructor + // automatically via [ActivatorUtilitiesConstructor]. + services.AddScoped(); } } diff --git a/src/TorBoxSDK/Http/Handlers/AuthHandler.cs b/src/TorBoxSDK/Http/Handlers/AuthHandler.cs index bfe5b83..7522b8c 100644 --- a/src/TorBoxSDK/Http/Handlers/AuthHandler.cs +++ b/src/TorBoxSDK/Http/Handlers/AuthHandler.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TorBoxSDK.Http.Validation; @@ -8,24 +9,40 @@ namespace TorBoxSDK.Http.Handlers; /// HTTP message handler that injects the TorBox API key as a Bearer token /// into the Authorization header of every outgoing request. /// -/// -/// Initializes a new instance of the class. -/// -/// The options containing the API key. -internal sealed class AuthHandler(IOptions options) : DelegatingHandler +internal sealed class AuthHandler : DelegatingHandler { - private readonly IOptions _options = Guard.ThrowIfNull(options); + private readonly string _apiKey; + + /// + /// Initializes a new instance of the class + /// for standalone usage with a direct API key. + /// + /// The TorBox API key. + internal AuthHandler(string apiKey) + { + _apiKey = apiKey ?? string.Empty; + } + + /// + /// Initializes a new instance of the class + /// for DI usage with . + /// + /// The options containing the API key. + [ActivatorUtilitiesConstructor] + public AuthHandler(IOptions options) + { + Guard.ThrowIfNull(options); + _apiKey = options.Value.ApiKey; + } /// protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - string apiKey = _options.Value.ApiKey; - - if (!string.IsNullOrWhiteSpace(apiKey)) + if (!string.IsNullOrWhiteSpace(_apiKey)) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); } return base.SendAsync(request, cancellationToken); diff --git a/src/TorBoxSDK/ITorBoxClient.cs b/src/TorBoxSDK/ITorBoxClient.cs index 6037a63..a592397 100644 --- a/src/TorBoxSDK/ITorBoxClient.cs +++ b/src/TorBoxSDK/ITorBoxClient.cs @@ -11,7 +11,7 @@ namespace TorBoxSDK; /// Provides access to the Main, Search, and Relay API clients /// that together cover the full TorBox platform surface. /// -public interface ITorBoxClient +public interface ITorBoxClient : IDisposable { /// /// Gets the Main API client, which exposes resource clients for diff --git a/src/TorBoxSDK/TorBoxClient.cs b/src/TorBoxSDK/TorBoxClient.cs index 09b6417..001468e 100644 --- a/src/TorBoxSDK/TorBoxClient.cs +++ b/src/TorBoxSDK/TorBoxClient.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TorBoxSDK.Http; +using TorBoxSDK.Http.Handlers; using TorBoxSDK.Http.Validation; using TorBoxSDK.Main; using TorBoxSDK.Relay; @@ -20,14 +22,23 @@ namespace TorBoxSDK; /// to access all SDK functionality. /// /// -/// Register the client through dependency injection using +/// The client can be used standalone (via new TorBoxClient(apiKey)) or +/// through dependency injection using /// . -/// This class cannot be instantiated directly — it is created internally -/// by the DI container. +/// +/// +/// In standalone mode, the client owns its instances +/// and must be disposed when no longer needed (preferably with a using statement). +/// In DI mode, the container manages the lifecycle and is a no-op. /// /// public sealed class TorBoxClient : ITorBoxClient { + private readonly HttpClient? _ownedMainClient; + private readonly HttpClient? _ownedSearchClient; + private readonly HttpClient? _ownedRelayClient; + private bool _disposed; + /// public IMainApiClient Main { get; } @@ -37,16 +48,76 @@ public sealed class TorBoxClient : ITorBoxClient /// public IRelayApiClient Relay { get; } + /// + /// Initializes a new standalone instance of the class + /// using the specified API key and default options. + /// + /// The TorBox API key used for Bearer authentication. + /// + /// Thrown when is or empty. + /// + public TorBoxClient(string apiKey) + : this(new TorBoxClientOptions { ApiKey = apiKey }) + { + } + + /// + /// Initializes a new standalone instance of the class + /// using the specified options. + /// + /// The SDK configuration options. + /// + /// Thrown when is . + /// + /// + /// Thrown when is or empty, + /// or when a base URL is not a valid absolute URI. + /// + public TorBoxClient(TorBoxClientOptions options) + { + Guard.ThrowIfNull(options); + Guard.ThrowIfNullOrEmpty(options.ApiKey, nameof(options.ApiKey)); + ValidateBaseUrl(options.MainApiVersionedUrl, nameof(options.MainApiBaseUrl)); + ValidateBaseUrl(options.SearchApiBaseUrl, nameof(options.SearchApiBaseUrl)); + ValidateBaseUrl(options.RelayApiVersionedUrl, nameof(options.RelayApiBaseUrl)); + + _ownedMainClient = CreateHttpClient(options.ApiKey, options.MainApiVersionedUrl, options.Timeout); + _ownedSearchClient = CreateHttpClient(options.ApiKey, options.SearchApiBaseUrl, options.Timeout); + _ownedRelayClient = CreateHttpClient(options.ApiKey, options.RelayApiVersionedUrl, options.Timeout); + + Main = new MainApiClient(_ownedMainClient, options.ApiKey, options.MainApiBaseUrl); + Search = new SearchApiClient(_ownedSearchClient); + Relay = new RelayApiClient(_ownedRelayClient, options.RelayApiBaseUrl); + } + + /// + /// Initializes a new standalone instance of the class + /// using a configuration delegate. + /// + /// A delegate to configure . + /// + /// Thrown when is . + /// + /// + /// Thrown when the configured is or empty. + /// + public TorBoxClient(Action configure) + : this(ApplyConfigure(configure)) + { + } + /// /// Initializes a new instance of the class /// using an to create the required HTTP clients. + /// This constructor is intended for DI usage. /// /// The HTTP client factory used to create named clients. /// The SDK configuration options. /// /// Thrown when or is . /// - internal TorBoxClient(IHttpClientFactory httpClientFactory, IOptions options) + [ActivatorUtilitiesConstructor] + public TorBoxClient(IHttpClientFactory httpClientFactory, IOptions options) { Guard.ThrowIfNull(httpClientFactory); Guard.ThrowIfNull(options); @@ -59,4 +130,48 @@ internal TorBoxClient(IHttpClientFactory httpClientFactory, IOptions + /// Releases the HTTP clients owned by this instance (standalone mode only). + /// In DI mode, this method is a no-op because the container manages the HTTP client lifecycle. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _ownedMainClient?.Dispose(); + _ownedSearchClient?.Dispose(); + _ownedRelayClient?.Dispose(); + _disposed = true; + } + + private static HttpClient CreateHttpClient(string apiKey, string baseUrl, TimeSpan timeout) + { + var authHandler = new AuthHandler(apiKey) { InnerHandler = new HttpClientHandler() }; + + return new HttpClient(authHandler) + { + BaseAddress = new Uri(baseUrl), + Timeout = timeout + }; + } + + private static TorBoxClientOptions ApplyConfigure(Action configure) + { + Guard.ThrowIfNull(configure); + TorBoxClientOptions options = new(); + configure(options); + return options; + } + + private static void ValidateBaseUrl(string url, string paramName) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out _)) + { + throw new ArgumentException($"'{url}' is not a valid absolute URI.", paramName); + } + } } diff --git a/src/TorBoxSDK/TorBoxClientOptions.cs b/src/TorBoxSDK/TorBoxClientOptions.cs index d61be49..48643ab 100644 --- a/src/TorBoxSDK/TorBoxClientOptions.cs +++ b/src/TorBoxSDK/TorBoxClientOptions.cs @@ -44,7 +44,11 @@ public sealed class TorBoxClientOptions /// /// Gets the fully-qualified Main API base URL including the version path. /// - internal string MainApiVersionedUrl + /// + /// Computed from and . + /// For example, https://api.torbox.app/v1/api/. + /// + public string MainApiVersionedUrl { get { @@ -82,7 +86,11 @@ internal string MainApiVersionedUrl /// /// Gets the fully-qualified Relay API base URL including the version path. /// - internal string RelayApiVersionedUrl + /// + /// Computed from and . + /// For example, https://relay.torbox.app/v1/. + /// + public string RelayApiVersionedUrl { get { From 7f528c630b20ab9aa1d9fcf666647a13ecd0d056 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:08:08 +0000 Subject: [PATCH 2/5] feat: add standalone tests, samples, and documentation for TorBoxClient - Unit tests: TorBoxClientTests.cs (10 tests) + AuthHandler string constructor tests (2 tests) - Integration tests: StandaloneClientTests.cs (2 tests, skip without API key) - Sample: StandaloneSetupExample.cs showing all 3 constructors - Program.cs: updated menu (38 examples) - README.md, docs/getting-started.md, docs/configuration.md, docs/architecture.md updated Agent-Logs-Url: https://github.com/devRael1/TorBoxSDK/sessions/872eff32-b524-402e-8001-6cb89ea05db2 Co-authored-by: devRael1 <91017912+devRael1@users.noreply.github.com> --- README.md | 48 ++++++ docs/architecture.md | 24 ++- docs/configuration.md | 34 +++- docs/getting-started.md | 28 ++++ .../GettingStarted/StandaloneSetupExample.cs | 115 ++++++++++++++ src/TorBoxSDK.Examples/Program.cs | 150 +++++++++--------- .../Standalone/StandaloneClientTests.cs | 53 +++++++ .../Http/AuthHandlerTests.cs | 36 +++++ .../TorboxSDK.UnitTests/TorBoxClientTests.cs | 118 ++++++++++++++ 9 files changed, 529 insertions(+), 77 deletions(-) create mode 100644 src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs create mode 100644 tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs create mode 100644 tests/TorboxSDK.UnitTests/TorBoxClientTests.cs diff --git a/README.md b/README.md index 575d0e1..0222c85 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,54 @@ catch (TorBoxException ex) } ``` +## Standalone Usage + +For console apps, scripts, or scenarios where dependency injection is not needed, create the client directly: + +```csharp +using TorBoxSDK; +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable."); + +using TorBoxClient client = new(apiKey); +using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + +try +{ + TorBoxResponse me = await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + Console.WriteLine($"Authenticated as: {me.Data?.Email}"); +} +catch (TorBoxException ex) +{ + Console.Error.WriteLine($"API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); +} +``` + +For more control over options: + +```csharp +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) +}); +``` + +Or use the builder pattern: + +```csharp +using TorBoxClient client = new(options => +{ + options.ApiKey = apiKey; + options.Timeout = TimeSpan.FromSeconds(60); +}); +``` + +`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP resources are released. In DI mode, the container manages disposal automatically. + ## Client Hierarchy The SDK is structured around a single root client with three API families: diff --git a/docs/architecture.md b/docs/architecture.md index 0773847..9a7eea2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ flowchart TD - **Search API**: search-oriented endpoints for torrents, usenet, metadata, Torznab, and Newznab - **Relay API**: relay status and inactivity checks -## DI and instantiation +## Instantiation `AddTorBox()` registers only `ITorBoxClient` in the DI container. All sub-clients (`MainApiClient`, `SearchApiClient`, `RelayApiClient`, and resource clients like `TorrentsClient`) are `internal` and instantiated by `TorBoxClient` itself. They are **not** individually resolvable from the service provider. @@ -62,6 +62,28 @@ provider.GetService(); // null provider.GetService(); // null ``` +`TorBoxClient` also supports standalone instantiation when dependency injection is not needed: + +```csharp +using TorBoxClient client = new("your-api-key"); + +using TorBoxClient configuredClient = new(new TorBoxClientOptions +{ + ApiKey = "your-api-key", + Timeout = TimeSpan.FromSeconds(60) +}); + +using TorBoxClient builtClient = new(options => +{ + options.ApiKey = "your-api-key"; + options.Timeout = TimeSpan.FromSeconds(60); +}); +``` + +The DI-focused constructor is marked with `[ActivatorUtilitiesConstructor]` so ASP.NET Core and other `Microsoft.Extensions.DependencyInjection` consumers choose the `IHttpClientFactory` + `IOptions` path automatically when resolving `ITorBoxClient`. + +`TorBoxClient` implements `IDisposable`. In standalone mode, it owns and disposes the underlying `HttpClient` instances, so it should be wrapped in a `using` statement. In DI mode, `Dispose()` is a no-op because the container manages the HTTP client lifecycle. + ## Cross-cutting behavior - Authentication uses a Bearer token attached by an internal `DelegatingHandler` diff --git a/docs/configuration.md b/docs/configuration.md index e723092..810fdd2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,9 +7,12 @@ TorBoxSDK is configured through `TorBoxClientOptions`. | Property | Required | Default | Notes | |---|---|---|---| | `ApiKey` | Yes | — | Required for authenticated TorBox requests | -| `MainApiBaseUrl` | No | `https://api.torbox.app/v1/api/` | Trailing slash should be preserved | +| `MainApiBaseUrl` | No | `https://api.torbox.app/` | Host URL for the Main API. Trailing slash should be preserved. | +| `ApiVersion` | Yes | `v1` | Version segment used to compute versioned Main and Relay API URLs | +| `MainApiVersionedUrl` | — | Computed | Full Main API URL with version (e.g. `https://api.torbox.app/v1/api/`). Read-only. | | `SearchApiBaseUrl` | No | `https://search-api.torbox.app/` | Trailing slash should be preserved | -| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Trailing slash should be preserved | +| `RelayApiBaseUrl` | No | `https://relay.torbox.app/` | Host URL for the Relay API. Trailing slash should be preserved. | +| `RelayApiVersionedUrl` | — | Computed | Full Relay API URL with version (e.g. `https://relay.torbox.app/v1/`). Read-only. | | `Timeout` | No | `00:00:30` | Applied to all configured `HttpClient` instances | ## Configure with code @@ -43,6 +46,33 @@ The SDK binds from the `TorBox` section: } ``` +## Configure without DI + +When using standalone mode, pass options directly to the constructor: + +```csharp +// API key only (default settings) +using TorBoxClient client = new("your-api-key"); + +// Full options object +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = "your-api-key", + MainApiBaseUrl = "https://api.torbox.app/", + ApiVersion = "v1", + SearchApiBaseUrl = "https://search-api.torbox.app/", + RelayApiBaseUrl = "https://relay.torbox.app/", + Timeout = TimeSpan.FromSeconds(60) +}); + +// Configuration delegate +using TorBoxClient client = new(options => +{ + options.ApiKey = "your-api-key"; + options.Timeout = TimeSpan.FromMinutes(2); +}); +``` + ## Registration overloads - `AddTorBox(Action)` diff --git a/docs/getting-started.md b/docs/getting-started.md index 9f06ed9..2dc3cab 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,6 +27,34 @@ using ServiceProvider provider = services.BuildServiceProvider(); ITorBoxClient client = provider.GetRequiredService(); ``` +## Use without dependency injection + +For console apps, scripts, or environments without a DI container, create the client directly: + +```csharp +using TorBoxSDK; + +string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException("Set the TORBOX_API_KEY environment variable."); + +using TorBoxClient client = new(apiKey); +``` + +You can also pass a `TorBoxClientOptions` instance or a configuration delegate: + +```csharp +using TorBoxClient client = new(new TorBoxClientOptions +{ + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) +}); +``` + +`TorBoxClient` implements `IDisposable`. Always use a `using` statement to ensure HTTP clients are properly released. In DI mode, the container manages the lifecycle automatically. + +> **When to choose standalone vs DI?** +> Use standalone for simple console tools, scripts, and one-off programs. Use DI for ASP.NET Core apps, hosted services, and anything with `IServiceCollection`. + ## Make your first requests ```csharp diff --git a/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs b/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs new file mode 100644 index 0000000..1ea710d --- /dev/null +++ b/src/TorBoxSDK.Examples/GettingStarted/StandaloneSetupExample.cs @@ -0,0 +1,115 @@ +using TorBoxSDK.Examples.Helpers; +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +namespace TorBoxSDK.Examples.GettingStarted; + +/// +/// Demonstrates standalone TorBoxClient usage without dependency injection. +/// Shows all three constructor patterns and IDisposable usage. +/// +public static class StandaloneSetupExample +{ + public static async Task RunAsync() + { + ExampleHelper.PrintHeader("Getting Started — Standalone Setup (No DI)"); + + // Always read API keys from environment variables — never hardcode them. + string apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY") + ?? throw new InvalidOperationException( + "Set the TORBOX_API_KEY environment variable."); + + // ────────────────────────────────────────────────────────── + // Pattern 1: Simple constructor with API key only. + // Uses default options (30s timeout, standard URLs). + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 1: Simple API key constructor ──"); + + using (TorBoxClient client = new(apiKey)) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + Console.WriteLine($" Plan: {response.Data.Plan}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + + // ────────────────────────────────────────────────────────── + // Pattern 2: Options object for full control. + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 2: Options object constructor ──"); + + using (TorBoxClient client = new(new TorBoxClientOptions + { + ApiKey = apiKey, + Timeout = TimeSpan.FromSeconds(60) + })) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(timeoutSeconds: 60); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + + // ────────────────────────────────────────────────────────── + // Pattern 3: Configuration delegate (builder pattern). + // ────────────────────────────────────────────────────────── + Console.WriteLine("── Pattern 3: Configuration delegate ──"); + + using (TorBoxClient client = new(options => + { + options.ApiKey = apiKey; + options.Timeout = TimeSpan.FromSeconds(45); + })) + { + using CancellationTokenSource cts = ExampleHelper.CreateTimeout(timeoutSeconds: 45); + + try + { + TorBoxResponse response = + await client.Main.User.GetMeAsync(cancellationToken: cts.Token); + + if (response.Data is not null) + { + Console.WriteLine($" Authenticated as: {response.Data.Email}"); + } + } + catch (TorBoxException ex) + { + Console.Error.WriteLine($" API error [{ex.ErrorCode}]: {ex.Detail ?? ex.Message}"); + } + } + + Console.WriteLine(); + Console.WriteLine("Standalone setup example completed."); + Console.WriteLine("Note: TorBoxClient implements IDisposable — always use 'using' statements."); + } +} diff --git a/src/TorBoxSDK.Examples/Program.cs b/src/TorBoxSDK.Examples/Program.cs index 2f63cc3..26b49b5 100644 --- a/src/TorBoxSDK.Examples/Program.cs +++ b/src/TorBoxSDK.Examples/Program.cs @@ -23,77 +23,78 @@ ║ Getting Started ║ ║ 1. Basic DI Setup ║ ║ 2. Configuration from appsettings.json ║ + ║ 3. Standalone Setup (No DI) ║ ║ ║ ║ ── Main Client ───────────────────────────────────── ║ ║ ║ ║ Main > Torrents ║ - ║ 3. List Torrents ║ - ║ 4. Create Torrent ║ - ║ 5. Control Torrent (pause/resume/delete) ║ - ║ 6. Download Torrent ║ - ║ 7. Check Cached ║ - ║ 8. Edit Torrent, Info by File & Magnet-to-File ║ + ║ 4. List Torrents ║ + ║ 5. Create Torrent ║ + ║ 6. Control Torrent (pause/resume/delete) ║ + ║ 7. Download Torrent ║ + ║ 8. Check Cached ║ + ║ 9. Edit Torrent, Info by File & Magnet-to-File ║ ║ ║ ║ Main > Usenet ║ - ║ 9. List Usenet Downloads ║ - ║ 10. Create Usenet Download ║ - ║ 11. Usenet Advanced (Cache, Edit, Async Create) ║ + ║ 10. List Usenet Downloads ║ + ║ 11. Create Usenet Download ║ + ║ 12. Usenet Advanced (Cache, Edit, Async Create) ║ ║ ║ ║ Main > Web Downloads ║ - ║ 12. List Web Downloads ║ - ║ 13. Create Web Download & Hosters ║ - ║ 14. Web Downloads Advanced (Cache, Edit, Async) ║ + ║ 13. List Web Downloads ║ + ║ 14. Create Web Download & Hosters ║ + ║ 15. Web Downloads Advanced (Cache, Edit, Async) ║ ║ ║ ║ Main > User ║ - ║ 15. Profile & Account Info ║ - ║ 16. Manage Settings ║ - ║ 17. Authentication & Device Auth ║ - ║ 18. Search Engines Management ║ - ║ 19. Transactions & PDF Export ║ + ║ 16. Profile & Account Info ║ + ║ 17. Manage Settings ║ + ║ 18. Authentication & Device Auth ║ + ║ 19. Search Engines Management ║ + ║ 20. Transactions & PDF Export ║ ║ ║ ║ Main > Notifications ║ - ║ 20. Manage Notifications ║ + ║ 21. Manage Notifications ║ ║ ║ ║ Main > RSS ║ - ║ 21. RSS Feeds ║ + ║ 22. RSS Feeds ║ ║ ║ ║ Main > Integrations ║ - ║ 22. Cloud Storage Integration (Google Drive) ║ - ║ 23. All Cloud Providers (Dropbox, OneDrive, etc.) ║ - ║ 24. OAuth & Discord Integration ║ - ║ 25. Job Management ║ + ║ 23. Cloud Storage Integration (Google Drive) ║ + ║ 24. All Cloud Providers (Dropbox, OneDrive, etc.) ║ + ║ 25. OAuth & Discord Integration ║ + ║ 26. Job Management ║ ║ ║ ║ Main > Vendors ║ - ║ 26. Vendor Account Management ║ + ║ 27. Vendor Account Management ║ ║ ║ ║ Main > Queued ║ - ║ 27. Queued Downloads ║ + ║ 28. Queued Downloads ║ ║ ║ ║ Main > Stream ║ - ║ 28. Stream Media Files ║ + ║ 29. Stream Media Files ║ ║ ║ ║ Main > General ║ - ║ 29. Service Status & Stats ║ - ║ 30. Speedtest Files ║ + ║ 30. Service Status & Stats ║ + ║ 31. Speedtest Files ║ ║ ║ ║ ── Search Client ─────────────────────────────────── ║ ║ ║ ║ Search ║ - ║ 31. Search Torrents ║ - ║ 32. Search Usenet ║ - ║ 33. Search Meta (Movies, TV) ║ - ║ 34. Search Tutorials ║ - ║ 35. Download Search Results & Get by ID ║ + ║ 32. Search Torrents ║ + ║ 33. Search Usenet ║ + ║ 34. Search Meta (Movies, TV) ║ + ║ 35. Search Tutorials ║ + ║ 36. Download Search Results & Get by ID ║ ║ ║ ║ ── Relay Client ──────────────────────────────────── ║ ║ ║ ║ Relay ║ - ║ 36. Relay Status & Inactivity Check ║ + ║ 37. Relay Status & Inactivity Check ║ ║ ║ ║ ── Other ─────────────────────────────────────────── ║ ║ ║ ║ Error Handling ║ - ║ 37. Comprehensive Error Handling Patterns ║ + ║ 38. Comprehensive Error Handling Patterns ║ ║ ║ ║ 0. Exit ║ ║ ║ @@ -102,12 +103,12 @@ while (true) { - Console.Write("Select an example (0-37): "); + Console.Write("Select an example (0-38): "); string? input = Console.ReadLine(); if (!int.TryParse(input, out int choice)) { - Console.WriteLine("Invalid input. Please enter a number between 0 and 37."); + Console.WriteLine("Invalid input. Please enter a number between 0 and 38."); continue; } @@ -117,9 +118,9 @@ break; } - if (choice is < 1 or > 37) + if (choice is < 1 or > 38) { - Console.WriteLine("Invalid choice. Please enter a number between 0 and 37."); + Console.WriteLine("Invalid choice. Please enter a number between 0 and 38."); continue; } @@ -130,69 +131,70 @@ // Getting Started 1 => BasicSetupExample.RunAsync(), 2 => ConfigurationExample.RunAsync(), + 3 => StandaloneSetupExample.RunAsync(), // Main > Torrents - 3 => ListTorrentsExample.RunAsync(), - 4 => CreateTorrentExample.RunAsync(), - 5 => ControlTorrentExample.RunAsync(), - 6 => DownloadTorrentExample.RunAsync(), - 7 => CheckCachedExample.RunAsync(), - 8 => EditTorrentExample.RunAsync(), + 4 => ListTorrentsExample.RunAsync(), + 5 => CreateTorrentExample.RunAsync(), + 6 => ControlTorrentExample.RunAsync(), + 7 => DownloadTorrentExample.RunAsync(), + 8 => CheckCachedExample.RunAsync(), + 9 => EditTorrentExample.RunAsync(), // Main > Usenet - 9 => ListUsenetExample.RunAsync(), - 10 => CreateUsenetExample.RunAsync(), - 11 => UsenetAdvancedExample.RunAsync(), + 10 => ListUsenetExample.RunAsync(), + 11 => CreateUsenetExample.RunAsync(), + 12 => UsenetAdvancedExample.RunAsync(), // Main > Web Downloads - 12 => ListWebDownloadsExample.RunAsync(), - 13 => CreateWebDownloadExample.RunAsync(), - 14 => WebDownloadsAdvancedExample.RunAsync(), + 13 => ListWebDownloadsExample.RunAsync(), + 14 => CreateWebDownloadExample.RunAsync(), + 15 => WebDownloadsAdvancedExample.RunAsync(), // Main > User - 15 => GetProfileExample.RunAsync(), - 16 => ManageSettingsExample.RunAsync(), - 17 => AuthenticationExample.RunAsync(), - 18 => SearchEnginesExample.RunAsync(), - 19 => TransactionsExample.RunAsync(), + 16 => GetProfileExample.RunAsync(), + 17 => ManageSettingsExample.RunAsync(), + 18 => AuthenticationExample.RunAsync(), + 19 => SearchEnginesExample.RunAsync(), + 20 => TransactionsExample.RunAsync(), // Main > Notifications - 20 => NotificationsExample.RunAsync(), + 21 => NotificationsExample.RunAsync(), // Main > RSS - 21 => RssFeedsExample.RunAsync(), + 22 => RssFeedsExample.RunAsync(), // Main > Integrations - 22 => CloudIntegrationExample.RunAsync(), - 23 => AllCloudProvidersExample.RunAsync(), - 24 => OAuthExample.RunAsync(), - 25 => JobManagementExample.RunAsync(), + 23 => CloudIntegrationExample.RunAsync(), + 24 => AllCloudProvidersExample.RunAsync(), + 25 => OAuthExample.RunAsync(), + 26 => JobManagementExample.RunAsync(), // Main > Vendors - 26 => VendorExample.RunAsync(), + 27 => VendorExample.RunAsync(), // Main > Queued - 27 => QueuedDownloadsExample.RunAsync(), + 28 => QueuedDownloadsExample.RunAsync(), // Main > Stream - 28 => StreamExample.RunAsync(), + 29 => StreamExample.RunAsync(), // Main > General - 29 => GeneralExample.RunAsync(), - 30 => SpeedtestExample.RunAsync(), + 30 => GeneralExample.RunAsync(), + 31 => SpeedtestExample.RunAsync(), // Search - 31 => SearchTorrentsExample.RunAsync(), - 32 => SearchUsenetExample.RunAsync(), - 33 => SearchMetaExample.RunAsync(), - 34 => SearchTutorialsExample.RunAsync(), - 35 => DownloadSearchResultsExample.RunAsync(), + 32 => SearchTorrentsExample.RunAsync(), + 33 => SearchUsenetExample.RunAsync(), + 34 => SearchMetaExample.RunAsync(), + 35 => SearchTutorialsExample.RunAsync(), + 36 => DownloadSearchResultsExample.RunAsync(), // Relay - 36 => RelayExample.RunAsync(), + 37 => RelayExample.RunAsync(), // Error Handling - 37 => ErrorHandlingExample.RunAsync(), + 38 => ErrorHandlingExample.RunAsync(), _ => Task.CompletedTask, }); diff --git a/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs new file mode 100644 index 0000000..839527c --- /dev/null +++ b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs @@ -0,0 +1,53 @@ +using TorBoxSDK.Models.Common; +using TorBoxSDK.Models.User; + +namespace TorBoxSDK.IntegrationTests.Standalone; + +/// +/// Integration tests that verify the standalone +/// constructors work end-to-end against the live TorBox API without +/// requiring a DI container. +/// +[Trait("Category", "Integration")] +public sealed class StandaloneClientTests +{ + [SkippableFact] + public async Task StandaloneClient_WithValidApiKey_CanCallApi() + { + // Skip when no API key is available. + string? apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "TORBOX_API_KEY not set."); + + // Arrange + using var client = new TorBoxClient(apiKey!); + using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1)); + + // Act + TorBoxResponse response = await client.Main.User.GetMeAsync( + cancellationToken: cts.Token); + + // Assert + Assert.True(response.Success); + Assert.NotNull(response.Data); + } + + [SkippableFact] + public async Task StandaloneClient_WithOptions_CanCallApi() + { + // Skip when no API key is available. + string? apiKey = Environment.GetEnvironmentVariable("TORBOX_API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "TORBOX_API_KEY not set."); + + // Arrange + using var client = new TorBoxClient(new TorBoxClientOptions { ApiKey = apiKey! }); + using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1)); + + // Act + TorBoxResponse response = await client.Main.User.GetMeAsync( + cancellationToken: cts.Token); + + // Assert + Assert.True(response.Success); + Assert.NotNull(response.Data); + } +} diff --git a/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs b/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs index 9a157c3..b488d0a 100644 --- a/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs +++ b/tests/TorboxSDK.UnitTests/Http/AuthHandlerTests.cs @@ -44,4 +44,40 @@ public async Task SendAsync_WithEmptyApiKey_DoesNotAddAuthorizationHeader() Assert.NotNull(innerHandler.LastRequest); Assert.Null(innerHandler.LastRequest.Headers.Authorization); } + + [Fact] + public async Task SendAsync_WithStringApiKey_SetsAuthorizationHeader() + { + // Arrange + var innerHandler = new MockHttpMessageHandler("""{"success":true,"error":null,"detail":"OK"}"""); + var authHandler = new AuthHandler("test-key") { InnerHandler = innerHandler }; + using var httpClient = new HttpClient(authHandler); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.torbox.app/v1/api/torrents/mylist"); + + // Act + await httpClient.SendAsync(request); + + // Assert + Assert.NotNull(innerHandler.LastRequest); + Assert.NotNull(innerHandler.LastRequest.Headers.Authorization); + Assert.Equal("Bearer", innerHandler.LastRequest.Headers.Authorization.Scheme); + Assert.Equal("test-key", innerHandler.LastRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task SendAsync_WithEmptyStringApiKey_DoesNotSetAuthorizationHeader() + { + // Arrange + var innerHandler = new MockHttpMessageHandler("""{"success":true,"error":null,"detail":"OK"}"""); + var authHandler = new AuthHandler(string.Empty) { InnerHandler = innerHandler }; + using var httpClient = new HttpClient(authHandler); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.torbox.app/v1/api/torrents/mylist"); + + // Act + await httpClient.SendAsync(request); + + // Assert + Assert.NotNull(innerHandler.LastRequest); + Assert.Null(innerHandler.LastRequest.Headers.Authorization); + } } diff --git a/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs new file mode 100644 index 0000000..9d06826 --- /dev/null +++ b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs @@ -0,0 +1,118 @@ +using TorBoxSDK; + +namespace TorboxSDK.UnitTests; + +public sealed class TorBoxClientTests +{ + [Fact] + public void Constructor_WithApiKey_CreatesClientWithAllSubClients() + { + // Arrange & Act + using var client = new TorBoxClient("test-key"); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithOptions_CreatesClientWithAllSubClients() + { + // Arrange + var options = new TorBoxClientOptions { ApiKey = "test-key" }; + + // Act + using var client = new TorBoxClient(options); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithConfigure_CreatesClientWithAllSubClients() + { + // Arrange & Act + using var client = new TorBoxClient(opts => opts.ApiKey = "test-key"); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Constructor_WithNullApiKey_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => new TorBoxClient((string)null!)); + } + + [Fact] + public void Constructor_WithEmptyApiKey_ThrowsArgumentException() + { + // Arrange & Act & Assert + Assert.Throws(() => new TorBoxClient(string.Empty)); + } + + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => new TorBoxClient((TorBoxClientOptions)null!)); + } + + [Fact] + public void Constructor_WithNullConfigure_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => new TorBoxClient((Action)null!)); + } + + [Fact] + public void Constructor_WithCustomTimeout_UsesProvidedTimeout() + { + // Arrange + var options = new TorBoxClientOptions + { + ApiKey = "test-key", + Timeout = TimeSpan.FromSeconds(120) + }; + + // Act + using var client = new TorBoxClient(options); + + // Assert + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Dispose_WhenStandalone_DisposesOwnedClients() + { + // Arrange + var client = new TorBoxClient("test-key"); + + // Act + client.Dispose(); + + // Assert — sub-client references remain valid (they are not nulled out) + Assert.NotNull(client.Main); + Assert.NotNull(client.Search); + Assert.NotNull(client.Relay); + } + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + // Arrange + var client = new TorBoxClient("test-key"); + + // Act & Assert — no exception on double dispose + client.Dispose(); + client.Dispose(); + } +} From 76351594de8c79faa96a0faca552f3359f02424c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:11:59 +0000 Subject: [PATCH 3/5] refactor: rename owned HttpClient fields for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename _ownedMainClient → _ownedMainHttpClient, _ownedSearchClient → _ownedSearchHttpClient, _ownedRelayClient → _ownedRelayHttpClient to distinguish from API client properties. Agent-Logs-Url: https://github.com/devRael1/TorBoxSDK/sessions/872eff32-b524-402e-8001-6cb89ea05db2 Co-authored-by: devRael1 <91017912+devRael1@users.noreply.github.com> --- src/TorBoxSDK/TorBoxClient.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/TorBoxSDK/TorBoxClient.cs b/src/TorBoxSDK/TorBoxClient.cs index 001468e..fa8f635 100644 --- a/src/TorBoxSDK/TorBoxClient.cs +++ b/src/TorBoxSDK/TorBoxClient.cs @@ -34,9 +34,9 @@ namespace TorBoxSDK; /// public sealed class TorBoxClient : ITorBoxClient { - private readonly HttpClient? _ownedMainClient; - private readonly HttpClient? _ownedSearchClient; - private readonly HttpClient? _ownedRelayClient; + private readonly HttpClient? _ownedMainHttpClient; + private readonly HttpClient? _ownedSearchHttpClient; + private readonly HttpClient? _ownedRelayHttpClient; private bool _disposed; /// @@ -81,13 +81,13 @@ public TorBoxClient(TorBoxClientOptions options) ValidateBaseUrl(options.SearchApiBaseUrl, nameof(options.SearchApiBaseUrl)); ValidateBaseUrl(options.RelayApiVersionedUrl, nameof(options.RelayApiBaseUrl)); - _ownedMainClient = CreateHttpClient(options.ApiKey, options.MainApiVersionedUrl, options.Timeout); - _ownedSearchClient = CreateHttpClient(options.ApiKey, options.SearchApiBaseUrl, options.Timeout); - _ownedRelayClient = CreateHttpClient(options.ApiKey, options.RelayApiVersionedUrl, options.Timeout); + _ownedMainHttpClient = CreateHttpClient(options.ApiKey, options.MainApiVersionedUrl, options.Timeout); + _ownedSearchHttpClient = CreateHttpClient(options.ApiKey, options.SearchApiBaseUrl, options.Timeout); + _ownedRelayHttpClient = CreateHttpClient(options.ApiKey, options.RelayApiVersionedUrl, options.Timeout); - Main = new MainApiClient(_ownedMainClient, options.ApiKey, options.MainApiBaseUrl); - Search = new SearchApiClient(_ownedSearchClient); - Relay = new RelayApiClient(_ownedRelayClient, options.RelayApiBaseUrl); + Main = new MainApiClient(_ownedMainHttpClient, options.ApiKey, options.MainApiBaseUrl); + Search = new SearchApiClient(_ownedSearchHttpClient); + Relay = new RelayApiClient(_ownedRelayHttpClient, options.RelayApiBaseUrl); } /// @@ -142,9 +142,9 @@ public void Dispose() return; } - _ownedMainClient?.Dispose(); - _ownedSearchClient?.Dispose(); - _ownedRelayClient?.Dispose(); + _ownedMainHttpClient?.Dispose(); + _ownedSearchHttpClient?.Dispose(); + _ownedRelayHttpClient?.Dispose(); _disposed = true; } From f15af3ae86b127b4adb32de6943197fb7a5dea6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:43:18 +0000 Subject: [PATCH 4/5] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix XML docs for TorBoxClient(string) — separate ArgumentNullException/ArgumentException - ValidateBaseUrl now enforces trailing '/' for correct relative URI resolution - Add [Collection("Integration")] to StandaloneClientTests for consistency - Add XML remarks about TORBOX_API_KEY requirement to StandaloneClientTests - Fix AAA convention in all TorBoxClientTests (proper Arrange/Act/Assert phases) - Constructor_WithCustomTimeout now asserts actual HttpClient.Timeout via reflection - Dispose_WhenStandalone now verifies ObjectDisposedException on disposed HttpClient Agent-Logs-Url: https://github.com/devRael1/TorBoxSDK/sessions/fdfcedb2-eec4-4c4c-b7d5-db781219d4e3 Co-authored-by: devRael1 <91017912+devRael1@users.noreply.github.com> --- src/TorBoxSDK/TorBoxClient.cs | 10 +- .../Standalone/StandaloneClientTests.cs | 5 + .../TorboxSDK.UnitTests/TorBoxClientTests.cs | 94 +++++++++++++------ 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/TorBoxSDK/TorBoxClient.cs b/src/TorBoxSDK/TorBoxClient.cs index fa8f635..971963d 100644 --- a/src/TorBoxSDK/TorBoxClient.cs +++ b/src/TorBoxSDK/TorBoxClient.cs @@ -53,8 +53,11 @@ public sealed class TorBoxClient : ITorBoxClient /// using the specified API key and default options. /// /// The TorBox API key used for Bearer authentication. + /// + /// Thrown when is . + /// /// - /// Thrown when is or empty. + /// Thrown when is empty. /// public TorBoxClient(string apiKey) : this(new TorBoxClientOptions { ApiKey = apiKey }) @@ -173,5 +176,10 @@ private static void ValidateBaseUrl(string url, string paramName) { throw new ArgumentException($"'{url}' is not a valid absolute URI.", paramName); } + + if (!url.EndsWith('/')) + { + throw new ArgumentException($"Base URL '{url}' must end with a trailing '/' for correct relative URI resolution.", paramName); + } } } diff --git a/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs index 839527c..0c07d3c 100644 --- a/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs +++ b/tests/TorBoxSDK.IntegrationTests/Standalone/StandaloneClientTests.cs @@ -8,6 +8,11 @@ namespace TorBoxSDK.IntegrationTests.Standalone; /// constructors work end-to-end against the live TorBox API without /// requiring a DI container. /// +/// +/// These tests require the TORBOX_API_KEY environment variable to be set. +/// When it is not available, tests are skipped gracefully via . +/// +[Collection("Integration")] [Trait("Category", "Integration")] public sealed class StandaloneClientTests { diff --git a/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs index 9d06826..a5e4659 100644 --- a/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs +++ b/tests/TorboxSDK.UnitTests/TorBoxClientTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using TorBoxSDK; namespace TorboxSDK.UnitTests; @@ -7,8 +8,11 @@ public sealed class TorBoxClientTests [Fact] public void Constructor_WithApiKey_CreatesClientWithAllSubClients() { - // Arrange & Act - using var client = new TorBoxClient("test-key"); + // Arrange + string apiKey = "test-key"; + + // Act + using TorBoxClient client = new(apiKey); // Assert Assert.NotNull(client.Main); @@ -20,10 +24,10 @@ public void Constructor_WithApiKey_CreatesClientWithAllSubClients() public void Constructor_WithOptions_CreatesClientWithAllSubClients() { // Arrange - var options = new TorBoxClientOptions { ApiKey = "test-key" }; + TorBoxClientOptions options = new() { ApiKey = "test-key" }; // Act - using var client = new TorBoxClient(options); + using TorBoxClient client = new(options); // Assert Assert.NotNull(client.Main); @@ -34,8 +38,11 @@ public void Constructor_WithOptions_CreatesClientWithAllSubClients() [Fact] public void Constructor_WithConfigure_CreatesClientWithAllSubClients() { - // Arrange & Act - using var client = new TorBoxClient(opts => opts.ApiKey = "test-key"); + // Arrange + Action configure = opts => opts.ApiKey = "test-key"; + + // Act + using TorBoxClient client = new(configure); // Assert Assert.NotNull(client.Main); @@ -46,73 +53,100 @@ public void Constructor_WithConfigure_CreatesClientWithAllSubClients() [Fact] public void Constructor_WithNullApiKey_ThrowsArgumentNullException() { - // Arrange & Act & Assert - Assert.Throws(() => new TorBoxClient((string)null!)); + // Arrange + string apiKey = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(apiKey)); } [Fact] public void Constructor_WithEmptyApiKey_ThrowsArgumentException() { - // Arrange & Act & Assert - Assert.Throws(() => new TorBoxClient(string.Empty)); + // Arrange + string apiKey = string.Empty; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(apiKey)); } [Fact] public void Constructor_WithNullOptions_ThrowsArgumentNullException() { - // Arrange & Act & Assert - Assert.Throws(() => new TorBoxClient((TorBoxClientOptions)null!)); + // Arrange + TorBoxClientOptions options = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(options)); } [Fact] public void Constructor_WithNullConfigure_ThrowsArgumentNullException() { - // Arrange & Act & Assert - Assert.Throws(() => new TorBoxClient((Action)null!)); + // Arrange + Action configure = null!; + + // Act & Assert + Assert.Throws(() => new TorBoxClient(configure)); } [Fact] public void Constructor_WithCustomTimeout_UsesProvidedTimeout() { // Arrange - var options = new TorBoxClientOptions + TimeSpan expectedTimeout = TimeSpan.FromSeconds(120); + TorBoxClientOptions options = new() { ApiKey = "test-key", - Timeout = TimeSpan.FromSeconds(120) + Timeout = expectedTimeout }; // Act - using var client = new TorBoxClient(options); + using TorBoxClient client = new(options); - // Assert - Assert.NotNull(client.Main); - Assert.NotNull(client.Search); - Assert.NotNull(client.Relay); + // Assert — verify owned HttpClient has the configured timeout via reflection + HttpClient? ownedHttpClient = typeof(TorBoxClient) + .GetField("_ownedMainHttpClient", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(client) as HttpClient; + + Assert.NotNull(ownedHttpClient); + Assert.Equal(expectedTimeout, ownedHttpClient.Timeout); } [Fact] - public void Dispose_WhenStandalone_DisposesOwnedClients() + public void Dispose_WhenStandalone_DisposesOwnedHttpClients() { // Arrange - var client = new TorBoxClient("test-key"); + TorBoxClient client = new("test-key"); + + HttpClient? ownedHttpClient = typeof(TorBoxClient) + .GetField("_ownedMainHttpClient", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(client) as HttpClient; + + Assert.NotNull(ownedHttpClient); // Act client.Dispose(); - // Assert — sub-client references remain valid (they are not nulled out) - Assert.NotNull(client.Main); - Assert.NotNull(client.Search); - Assert.NotNull(client.Relay); + // Assert — a disposed HttpClient throws ObjectDisposedException on use + Assert.Throws(() => + { + using HttpRequestMessage request = new(HttpMethod.Get, "https://example.com"); + ownedHttpClient.Send(request); + }); } [Fact] public void Dispose_CalledTwice_DoesNotThrow() { // Arrange - var client = new TorBoxClient("test-key"); + TorBoxClient client = new("test-key"); - // Act & Assert — no exception on double dispose - client.Dispose(); + // Act client.Dispose(); + + // Assert — second dispose does not throw + Exception? exception = Record.Exception(() => client.Dispose()); + Assert.Null(exception); } } From d1aab4e110875820186e7276bf17f27b78185b09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:40:13 +0000 Subject: [PATCH 5/5] ci: make workflows manual-only (workflow_dispatch) - Remove `pull_request_review` trigger from both ci.yml and integration-tests.yml - Remove `check-approval` job and all review-approval logic from both workflows - Remove `pull-requests: read` permission (no longer needed) - Both workflows now run only via manual dispatch Agent-Logs-Url: https://github.com/devRael1/TorBoxSDK/sessions/c524e4af-a51a-4098-8ff4-ae57948ea792 Co-authored-by: devRael1 <91017912+devRael1@users.noreply.github.com> --- .github/workflows/ci.yml | 60 --------------------- .github/workflows/integration-tests.yml | 70 +------------------------ 2 files changed, 2 insertions(+), 128 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e078629..e8421c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,74 +1,14 @@ name: CI on: - pull_request_review: - types: [submitted] workflow_dispatch: permissions: contents: read - pull-requests: read jobs: - check-approval: - name: Check All Reviews Approved - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') - runs-on: ubuntu-latest - outputs: - approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }} - steps: - - id: check - if: github.event_name == 'pull_request_review' - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - - // Get the current PR to check for pending review requests - const { data: currentPr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // If there are still pending review requests, not everyone has approved - if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) { - core.setOutput('approved', 'false'); - core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`); - return; - } - - // Get all reviews - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // Get latest review state per reviewer (excluding the PR author and COMMENTED-only) - const latestByReviewer = new Map(); - for (const review of reviews) { - if (review.user.login === currentPr.user.login) continue; - if (review.state === 'COMMENTED') continue; - latestByReviewer.set(review.user.login, review.state); - } - - // All latest non-comment reviews must be APPROVED and there must be at least one - const states = [...latestByReviewer.values()]; - const allApproved = states.length > 0 && states.every(s => s === 'APPROVED'); - core.setOutput('approved', allApproved ? 'true' : 'false'); - core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`); - - - id: dispatch - if: github.event_name == 'workflow_dispatch' - run: echo "approved=true" >> "$GITHUB_OUTPUT" - build: name: Build - needs: check-approval - if: needs.check-approval.outputs.approved == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cc918ca..99f41fe 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,8 +1,6 @@ name: Integration & Live Schema Tests on: - pull_request_review: - types: [submitted] workflow_dispatch: inputs: run_live_schema: @@ -18,71 +16,11 @@ on: permissions: contents: read - pull-requests: read jobs: - check-approval: - name: Check All Reviews Approved - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') - runs-on: ubuntu-latest - outputs: - approved: ${{ steps.check.outputs.approved || steps.dispatch.outputs.approved }} - steps: - - id: check - if: github.event_name == 'pull_request_review' - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - - // Get the current PR to check for pending review requests - const { data: currentPr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // If there are still pending review requests, not everyone has approved - if (currentPr.requested_reviewers.length > 0 || currentPr.requested_teams.length > 0) { - core.setOutput('approved', 'false'); - core.info(`PR has ${currentPr.requested_reviewers.length} pending reviewer(s) and ${currentPr.requested_teams.length} pending team(s).`); - return; - } - - // Get all reviews - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // Get latest review state per reviewer (excluding the PR author and COMMENTED-only) - const latestByReviewer = new Map(); - for (const review of reviews) { - if (review.user.login === currentPr.user.login) continue; - if (review.state === 'COMMENTED') continue; - latestByReviewer.set(review.user.login, review.state); - } - - // All latest non-comment reviews must be APPROVED and there must be at least one - const states = [...latestByReviewer.values()]; - const allApproved = states.length > 0 && states.every(s => s === 'APPROVED'); - core.setOutput('approved', allApproved ? 'true' : 'false'); - core.info(`Reviews: ${JSON.stringify(Object.fromEntries(latestByReviewer))} → allApproved=${allApproved}`); - - - id: dispatch - if: github.event_name == 'workflow_dispatch' - run: echo "approved=true" >> "$GITHUB_OUTPUT" - integration-tests: name: Integration Tests - needs: check-approval - if: > - needs.check-approval.outputs.approved == 'true' && - (github.event_name == 'workflow_dispatch' && inputs.run_integration == true || - github.event_name == 'pull_request_review') + if: inputs.run_integration == true runs-on: ubuntu-latest environment: testing steps: @@ -121,11 +59,7 @@ jobs: live-schema-validation: name: Live Schema Validation - needs: check-approval - if: > - needs.check-approval.outputs.approved == 'true' && - (github.event_name == 'workflow_dispatch' && inputs.run_live_schema == true || - github.event_name == 'pull_request_review') + if: inputs.run_live_schema == true runs-on: ubuntu-latest environment: testing steps: