diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..89efcb1
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,33 @@
+{
+ "permissions": {
+ "allow": [
+ "mcp__Rider__list_directory_tree",
+ "mcp__Rider__find_files_by_name_keyword",
+ "mcp__Rider__get_run_configurations",
+ "mcp__Rider__search_in_files_by_text",
+ "Bash(dotnet build:*)",
+ "mcp__fetch__fetch",
+ "Bash(dotnet test:*)",
+ "mcp__Rider__get_file_text_by_path",
+ "mcp__sequentialthinking__sequentialthinking",
+ "mcp__MicrosoftLearn__microsoft_docs_search",
+ "mcp__MicrosoftLearn__microsoft_docs_fetch",
+ "mcp__Rider__replace_text_in_file",
+ "Bash(dir:*)",
+ "WebSearch",
+ "Bash(dotnet --version:*)",
+ "WebFetch(domain:github.com)",
+ "WebFetch(domain:stackoverflow.com)",
+ "Bash(dotnet add:*)",
+ "mcp__MicrosoftLearn__microsoft_code_sample_search",
+ "Bash(del \"C:\\Coding\\CSharp\\Personal\\Libraries\\CatBox.NET\\src\\CatBox.NET\\Client\\Helpers.cs\")",
+ "Bash(git commit:*)",
+ "Bash(git reset:*)",
+ "Bash(git add:*)",
+ "Bash(dotnet new:*)",
+ "Bash(cat:*)"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
diff --git a/.editorconfig b/.editorconfig
index ea9b9cb..eb29ae6 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,12 +1,14 @@
-
[*]
charset = utf-8-bom
end_of_line = crlf
trim_trailing_whitespace = false
insert_final_newline = false
indent_style = space
-indent_size = 4
+[{*.yml,*.json,*.xml,*.csproj,*.slnx}]
+indent_size = 2
+
+[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}]
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
@@ -75,7 +77,6 @@ resharper_web_config_module_not_resolved_highlighting = warning
resharper_web_config_type_not_resolved_highlighting = warning
resharper_web_config_wrong_module_highlighting = warning
-[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}]
indent_style = space
indent_size = 4
tab_width = 4
diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml
index 1d8b6a3..5c06558 100644
--- a/.github/workflows/qodana_code_quality.yml
+++ b/.github/workflows/qodana_code_quality.yml
@@ -1,3 +1,4 @@
+# Test Change 2
name: Qodana
on:
workflow_dispatch:
diff --git a/.idea/.idea.CatBox.NET/.idea/projectSettingsUpdater.xml b/.idea/.idea.CatBox.NET/.idea/projectSettingsUpdater.xml
new file mode 100644
index 0000000..ef20cb0
--- /dev/null
+++ b/.idea/.idea.CatBox.NET/.idea/projectSettingsUpdater.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b4896d9
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "dotnet.defaultSolution": "CatBox.NET.sln"
+}
\ No newline at end of file
diff --git a/CatBox.NET.sln b/CatBox.NET.sln
index 3dfccfb..67a33f0 100644
--- a/CatBox.NET.sln
+++ b/CatBox.NET.sln
@@ -1,4 +1,3 @@
-
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatBox.NET", "src\CatBox.NET\CatBox.NET.csproj", "{CDEF8297-E053-495F-960C-F3EBAEA7E71F}"
EndProject
diff --git a/CatBox.NET.slnx b/CatBox.NET.slnx
new file mode 100644
index 0000000..b479f49
--- /dev/null
+++ b/CatBox.NET.slnx
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..512142d
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/qodana.yaml b/qodana.yaml
index 04b02b1..a1f56ad 100644
--- a/qodana.yaml
+++ b/qodana.yaml
@@ -3,7 +3,10 @@
####################################################################################################################
version: "1.0"
-linter: jetbrains/qodana-cdnet:2025.2
+linter: jetbrains/qodana-cdnet:latest
+bootstrap: curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --jsonfile /data/project/global.json -i /usr/share/dotnet
+dotnet:
+ solution: CatBox.NET.sln
profile:
name: qodana.recommended
include:
diff --git a/readme.md b/readme.md
index 588692e..5c39060 100644
--- a/readme.md
+++ b/readme.md
@@ -11,13 +11,4 @@ CatBox.NET is a library for uploading images to the [CatBox.moe](https://catbox.
## Getting Started
-Head over [to our wiki](https://github.com/ChaseDRedmon/CatBox.NET/wiki) to get started on how to use the library.
-
-## Prerequisites
-Change your Language Version to C# 11, by adding the following code to your `.csproj` file. Make sure the `` tag is added under your ``
-
-```csharp
-
- 11
-
-```
\ No newline at end of file
+Head over [to our wiki](https://github.com/ChaseDRedmon/CatBox.NET/wiki) to get started on how to use the library.
\ No newline at end of file
diff --git a/samples/SampleApp/Program.cs b/samples/SampleApp/Program.cs
index 5af1bc2..d0a6169 100644
--- a/samples/SampleApp/Program.cs
+++ b/samples/SampleApp/Program.cs
@@ -1,41 +1,109 @@
-// See https://aka.ms/new-console-template for more information
-
using CatBox.NET;
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+using CatBox.NET.Requests.File;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Serilog;
+using Microsoft.Extensions.Logging;
+
+// Load configuration from user secrets
+// To set your UserHash, run: dotnet user-secrets set "CatBox:UserHash" "your-hash-here" --project samples/SampleApp
+// Get your UserHash from: https://catbox.moe/user/manage.php
+var configuration = new ConfigurationBuilder()
+ .AddUserSecrets()
+ .Build();
+
+var userHash = configuration["CatBox:UserHash"]
+ ?? throw new InvalidOperationException(
+ "CatBox UserHash not configured. Run: dotnet user-secrets set \"CatBox:UserHash\" \"your-hash-here\" --project samples/SampleApp");
-Log.Logger = new LoggerConfiguration()
- .MinimumLevel.Verbose()
- .WriteTo.Console()
- .CreateLogger();
+// Compute path to test image file within the project directory
+string testImagePath = Path.Combine(
+ AppDomain.CurrentDomain.BaseDirectory,
+ "..", "..", "..", "..", "..", // Navigate up from bin/Debug/net7.0
+ "tests", "CatBox.Tests", "Images", "test-file.png"
+);
+testImagePath = Path.GetFullPath(testImagePath); // Normalize the path
+string testImageFileName = Path.GetFileName(testImagePath);
+
+// Verify the test image file exists
+if (!File.Exists(testImagePath))
+{
+ throw new FileNotFoundException($"Test image not found at: {testImagePath}");
+}
var collection = new ServiceCollection()
- .AddCatBoxServices(f => f.CatBoxUrl = new Uri("https://catbox.moe/user/api.php"))
- .AddLogging(f => f.AddSerilog(dispose: true))
+ .AddCatBoxServices(f =>
+ {
+ f.CatBoxUrl = new Uri("https://catbox.moe/user/api.php");
+ f.LitterboxUrl = new Uri("https://litterbox.catbox.moe/resources/internals/api.php");
+ })
+ .AddLogging(f => f.AddConsole())
.BuildServiceProvider();
-// Upload a single image
+// Store uploaded file URLs and album URL for cleanup
+var uploadedFiles = new List();
+string? albumUrl = null;
+
+// Upload a single image via stream
+using (var scope = collection.CreateScope())
+{
+ var client = scope.ServiceProvider.GetRequiredService();
+ var responses = client.UploadFilesAsStreamAsync([new StreamUploadRequest
+ {
+ Stream = File.OpenRead(testImagePath),
+ FileName = testImageFileName,
+ UserHash = userHash
+ }]);
+
+ await foreach (var response in responses)
+ {
+ Console.WriteLine(response);
+ if (!string.IsNullOrWhiteSpace(response))
+ uploadedFiles.Add(response);
+ }
+}
+
+// Create an album of images already on Catbox
using (var scope = collection.CreateScope())
{
var client = scope.ServiceProvider.GetRequiredService();
- var response = await client.UploadImage(new StreamUploadRequest
+ var response = await client.CreateAlbumAsync(new RemoteCreateAlbumRequest
{
- Stream = File.OpenRead(@"C:\Users\redmo\Documents\Anime\13c9a4.png"),
- FileName = Path.GetFileName(@"C:\Users\redmo\Documents\Anime\13c9a4.png")
+ Title = "Album Title",
+ Description = "Album Description",
+ Files = uploadedFiles, // Use the actual uploaded file(s) from previous step
+ UserHash = userHash
});
Console.WriteLine(response);
+ albumUrl = response;
}
-// Create an album of images already on Catbox
+// Cleanup: Delete the album and uploaded files
using (var scope = collection.CreateScope())
{
var client = scope.ServiceProvider.GetRequiredService();
- var response = await client.CreateAlbum(new CreateAlbumRequest
+ await CleanupAsync(client, albumUrl, uploadedFiles, userHash);
+}
+
+return;
+
+/*// Upload images to CatBox, then create an album on CatBox, then place the uploaded images into the newly created album
+using (var scope = collection.CreateScope())
+{
+ var client = scope.ServiceProvider.GetRequiredService();
+ var response = await client.CreateAlbumFromFilesAsync(new CreateAlbumRequest
{
Title = "Album Title",
Description = "Album Description",
- Files = new [] { "1.jpg", }
+ UserHash = null,
+ UploadRequest = new FileUploadRequest
+ {
+ Files = [new FileInfo(testImagePath)]
+ }
});
Console.WriteLine(response);
@@ -45,14 +113,79 @@
using (var scope = collection.CreateScope())
{
var client = scope.ServiceProvider.GetRequiredService();
- var response = await client.UploadImage(new TemporaryStreamUploadRequest
+ var response = await client.UploadImageAsync(new TemporaryStreamUploadRequest
{
- ExpireAfter = ExpireAfter.OneHour,
- FileName = Path.GetFileName(@"C:\Users\redmo\Documents\Anime\13c9a4.png"),
- Stream = File.OpenRead(@"C:\Users\redmo\Documents\Anime\13c9a4.png")
+ Expiry = ExpireAfter.OneHour,
+ FileName = testImageFileName,
+ Stream = File.OpenRead(testImagePath)
});
Console.WriteLine(response);
+
+ Console.WriteLine();
}
-Console.ReadLine();
\ No newline at end of file
+Console.ReadLine();*/
+
+// Cleanup method to delete album and uploaded files
+static async Task CleanupAsync(ICatBoxClient client, string? albumUrl, List uploadedFiles, string userHash)
+{
+ Console.WriteLine("\n--- Starting Cleanup ---");
+
+ // Delete the album first
+ if (!string.IsNullOrWhiteSpace(albumUrl))
+ {
+ try
+ {
+ // Extract album short ID from URL (e.g., "pd412w" from "https://catbox.moe/c/pd412w")
+ var albumUri = new Uri(albumUrl);
+ var albumId = albumUri.AbsolutePath.TrimStart('/').Replace("c/", "");
+
+ Console.WriteLine($"Deleting album: {albumId}");
+
+ var albumDeleteResponse = await client.ModifyAlbumAsync(new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.DeleteAlbum,
+ UserHash = userHash,
+ AlbumId = albumId,
+ Files = [] // Empty for DeleteAlbum operation
+ });
+
+ Console.WriteLine($"Album deletion response: {albumDeleteResponse}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting album: {ex.Message}");
+ }
+ }
+
+ // Delete the uploaded files
+ if (uploadedFiles.Count > 0)
+ {
+ try
+ {
+ // Extract filenames from URLs (e.g., "8ce67f.jpg" from "https://files.catbox.moe/8ce67f.jpg")
+ var fileNames = uploadedFiles.Select(url =>
+ {
+ var uri = new Uri(url);
+ return Path.GetFileName(uri.AbsolutePath);
+ }).ToList();
+
+ Console.WriteLine($"Deleting {fileNames.Count} file(s): {string.Join(", ", fileNames)}");
+
+ var fileDeleteResponse = await client.DeleteMultipleFilesAsync(new DeleteFileRequest
+ {
+ UserHash = userHash,
+ FileNames = fileNames
+ });
+
+ Console.WriteLine($"File deletion response: {fileDeleteResponse}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting files: {ex.Message}");
+ }
+ }
+
+ Console.WriteLine("--- Cleanup Complete ---\n");
+}
diff --git a/samples/SampleApp/SampleApp.csproj b/samples/SampleApp/SampleApp.csproj
index 6ac7772..ae05101 100644
--- a/samples/SampleApp/SampleApp.csproj
+++ b/samples/SampleApp/SampleApp.csproj
@@ -1,20 +1,23 @@
-
- Exe
- net7.0
- enable
- enable
- false
- 11
-
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ latest
+ catbox-net-sample-app
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/CatBox.NET/CatBox.NET.csproj b/src/CatBox.NET/CatBox.NET.csproj
index 3986475..e510aed 100644
--- a/src/CatBox.NET/CatBox.NET.csproj
+++ b/src/CatBox.NET/CatBox.NET.csproj
@@ -1,28 +1,28 @@
-
- enable
- enable
- 11
- 0.3.0
- Chase Redmon, Kuinox, Adam Sears
- CatBox.NET is a .NET Library for uploading files, URLs, and modifying albums on CatBox.moe
- https://github.com/ChaseDRedmon/CatBox.NET
- https://github.com/ChaseDRedmon/CatBox.NET
- Library
- Catbox, Catbox.moe, Imgur
- Copyright © 2023 Chase Redmon
- https://github.com/ChaseDRedmon/CatBox.NET/blob/main/license.txt
- Fix required description field on create album endpoint. Description is optional when creating an endpoint.
- net7.0;netstandard2.1
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+ enable
+ enable
+ latest
+ 1.0
+ Chase Redmon, Kuinox, Adam Sears
+ CatBox.NET is a .NET Library for uploading files, URLs, and modifying albums on CatBox.moe
+ https://github.com/ChaseDRedmon/CatBox.NET
+ https://github.com/ChaseDRedmon/CatBox.NET
+ Library
+ Catbox, Catbox.moe, Imgur, GfyCat
+ Copyright © 2025 Chase Redmon
+ https://github.com/ChaseDRedmon/CatBox.NET/blob/main/license.txt
+ Fix required description field on create album endpoint. Description is optional when creating an endpoint.
+ net10.0
+
+
+
+
+
+
+
+
+
diff --git a/src/CatBox.NET/CatBoxServices.cs b/src/CatBox.NET/CatBoxServices.cs
index 5a11b80..b532b1f 100644
--- a/src/CatBox.NET/CatBoxServices.cs
+++ b/src/CatBox.NET/CatBoxServices.cs
@@ -1,28 +1,58 @@
using CatBox.NET.Client;
+using CatBox.NET.Exceptions;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Http.Resilience;
+using Polly;
namespace CatBox.NET;
public static class CatBoxServices
{
- ///
- /// Add the internal services that the library uses to the DI container
- ///
/// Service Collection
- /// Configure the URL to upload files too
- /// Service Collection
- public static IServiceCollection AddCatBoxServices(this IServiceCollection services, Action setupAction)
+ extension(IServiceCollection services)
{
- services
- .Configure(setupAction)
- .AddScoped()
- .AddScoped()
- .AddScoped()
- .AddScoped()
- .AddHttpClient();
+ ///
+ /// Add the internal services that the library uses to the DI container
+ ///
+ /// Configure the URL to upload files too
+ /// Service Collection
+ public IServiceCollection AddCatBoxServices(Action setupAction)
+ {
+ services
+ .Configure(setupAction)
+ .AddTransient()
+ .AddScoped()
+ .AddScoped()
+ .AddScoped()
+ .AddHttpClientWithMessageHandler()
+ .AddHttpClientWithMessageHandler();
- services.AddHttpClient();
+ return services;
+ }
- return services;
+ private IServiceCollection AddHttpClientWithMessageHandler()
+ where TInterface : class
+ where TImplementation : class, TInterface
+ {
+ services
+ .AddHttpClient(client =>
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(".NET/10 CatBox.NET/1.0 (+https://github.com/ChaseDRedmon/CatBox.NET)"))
+ .AddHttpMessageHandler()
+ .AddStandardResilienceHandler(options =>
+ {
+ // Disable retries for unsafe HTTP methods to prevent duplicate uploads/album operations
+ options.Retry.DisableForUnsafeHttpMethods();
+
+ options.Retry = new HttpRetryStrategyOptions
+ {
+ BackoffType = DelayBackoffType.Exponential,
+ UseJitter = true,
+ Delay = TimeSpan.FromSeconds(5),
+ MaxRetryAttempts = 5
+ };
+ });
+
+ return services;
+ }
}
}
\ No newline at end of file
diff --git a/src/CatBox.NET/CatBoxConfig.cs b/src/CatBox.NET/CatboxOptions.cs
similarity index 90%
rename from src/CatBox.NET/CatBoxConfig.cs
rename to src/CatBox.NET/CatboxOptions.cs
index 3b3f84a..45a6aea 100644
--- a/src/CatBox.NET/CatBoxConfig.cs
+++ b/src/CatBox.NET/CatboxOptions.cs
@@ -3,7 +3,7 @@
///
/// Configuration object for storing URLs to the API
///
-public record CatBoxConfig
+public sealed record CatboxOptions
{
///
/// URL for the catbox.moe domain
diff --git a/src/CatBox.NET/Client/CatBox.cs b/src/CatBox.NET/Client/CatBox.cs
deleted file mode 100644
index 65592e7..0000000
--- a/src/CatBox.NET/Client/CatBox.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-namespace CatBox.NET.Client;
-
-[Obsolete("Do not use at this time")]
-public sealed class Catbox : ICatBox
-{
- private readonly ICatBoxClient _client;
-
- ///
- /// Instantiate a new catbox class
- ///
- ///
- ///
- ///
- public Catbox(ICatBoxClient client)
- {
- _client = client;
- }
-
- public Task UploadFile()
- {
- throw new NotImplementedException();
- }
-
- public Task DeleteFile()
- {
- throw new NotImplementedException();
- }
-
- public Task CreateAlbum()
- {
- throw new NotImplementedException();
- }
-
- public Task EditAlbum()
- {
- throw new NotImplementedException();
- }
-
- public Task AddToAlbum()
- {
- throw new NotImplementedException();
- }
-
- public Task RemoveFromAlbum()
- {
- throw new NotImplementedException();
- }
-
- public Task DeleteAlbum()
- {
- throw new NotImplementedException();
- }
-}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/CatBox/CatBox.cs b/src/CatBox.NET/Client/CatBox/CatBox.cs
new file mode 100644
index 0000000..c6746d0
--- /dev/null
+++ b/src/CatBox.NET/Client/CatBox/CatBox.cs
@@ -0,0 +1,94 @@
+using CatBox.NET.Requests.Album;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+
+namespace CatBox.NET.Client;
+
+///
+/// Provides an abstraction over to group multiple tasks together
+///
+public interface ICatBox
+{
+ ///
+ /// Creates an album on CatBox from files that are uploaded in the requestBase
+ ///
+ /// Album Creation Request
+ /// Cancellation Token.
+ ///
+ Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default);
+
+ ///
+ /// Upload and add images to an existing Catbox Album
+ ///
+ /// Album Creation Request
+ /// Cancellation Token.
+ ///
+ Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default);
+}
+
+///
+public sealed class Catbox : ICatBox
+{
+ private readonly ICatBoxClient _client;
+
+ ///
+ /// Instantiate a new catbox class
+ ///
+ /// The CatBox Api Client ()
+ public Catbox(ICatBoxClient client)
+ {
+ _client = client;
+ }
+
+ ///
+ public Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default)
+ {
+ var enumerable = Upload(requestFromFiles, ct);
+
+ var createAlbumRequest = new RemoteCreateAlbumRequest
+ {
+ Title = requestFromFiles.Title,
+ Description = requestFromFiles.Description,
+ UserHash = requestFromFiles.UserHash,
+ Files = enumerable.ToBlockingEnumerable(cancellationToken: ct)
+ };
+
+ return _client.CreateAlbumAsync(createAlbumRequest, ct);
+ }
+
+ ///
+ public Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default)
+ {
+ var requestType = request.Request;
+ var userHash = request.UserHash;
+ var albumId = request.AlbumId;
+
+ var enumerable = Upload(request, ct);
+
+ return _client.ModifyAlbumAsync(new ModifyAlbumImagesRequest
+ {
+ Request = requestType,
+ UserHash = userHash,
+ AlbumId = albumId,
+ Files = enumerable.ToBlockingEnumerable()
+ }, ct);
+ }
+
+ ///
+ /// Upload files based on the requestBase type
+ ///
+ /// Upload requestBase type
+ /// Cancellation Token
+ /// API Response
+ /// When passing in an invalid requestBase type
+ private IAsyncEnumerable Upload(IAlbumUploadRequest request, CancellationToken ct = default)
+ {
+ return request.UploadRequest switch
+ {
+ { IsFirst: true } => _client.UploadFilesAsync(request.UploadRequest, ct),
+ { IsSecond: true } => _client.UploadFilesAsStreamAsync(request.UploadRequest.Second, ct),
+ { IsThird: true } => _client.UploadFilesAsUrlAsync(request.UploadRequest, ct),
+ _ => throw new InvalidOperationException("Invalid requestBase type")
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/CatBox/CatBoxClient.cs b/src/CatBox.NET/Client/CatBox/CatBoxClient.cs
new file mode 100644
index 0000000..158ce1a
--- /dev/null
+++ b/src/CatBox.NET/Client/CatBox/CatBoxClient.cs
@@ -0,0 +1,318 @@
+using System.Runtime.CompilerServices;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+using Microsoft.Extensions.Options;
+using static CatBox.NET.Client.Common;
+
+namespace CatBox.NET.Client;
+
+public interface ICatBoxClient
+{
+ ///
+ /// Enables uploading multiple files from disk (FileStream) to the API
+ ///
+ ///
+ /// Cancellation Token
+ /// When is null
+ /// Yield returns the CatBox filename of the uploaded image
+ IAsyncEnumerable UploadFilesAsync(FileUploadRequest fileUploadRequest, CancellationToken ct = default);
+
+ ///
+ /// Enables uploading multiple files by URL to the API
+ ///
+ /// Data to send to the API
+ /// Cancellation Token
+ /// When is null
+ /// when something bad happens when talking to the API
+ /// Yield returns the CatBox filename of the uploaded image
+ IAsyncEnumerable UploadFilesAsUrlAsync(UrlUploadRequest urlUploadRequest, CancellationToken ct = default);
+
+ ///
+ /// Deletes multiple files by API file name
+ ///
+ /// Files to delete from the server
+ /// Cancellation Token
+ /// When is null
+ /// When is null
+ /// When is null, empty, or whitespace
+ /// when something bad happens when talking to the API
+ /// Response string from the API
+ Task DeleteMultipleFilesAsync(DeleteFileRequest deleteFileRequest, CancellationToken ct = default);
+
+ ///
+ /// Streams a single image to be uploaded
+ ///
+ ///
+ /// Cancellation Token
+ /// When is null
+ /// When is null
+ /// when something bad happens when talking to the API
+ /// Returns the CatBox filename of the uploaded image
+ IAsyncEnumerable UploadFilesAsStreamAsync(IEnumerable fileUploadRequest, CancellationToken ct = default);
+
+ ///
+ /// Creates an album on CatBox via provided file names generated by the API
+ ///
+ /// Data to pass to the API
+ /// Cancellation Token
+ /// when is null
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when something bad happens when talking to the API
+ /// Returns the created album URL
+ Task CreateAlbumAsync(RemoteCreateAlbumRequest remoteCreateAlbumRequest, CancellationToken ct = default);
+
+ ///
+ /// Edits the content of album according to the content that is passed to the API
+ ///
+ /// Data to pass to the API
+ /// Cancellation Token
+ /// when is null
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when something bad happens when talking to the API
+ /// Response string from the API
+ Task EditAlbumAsync(EditAlbumRequest editAlbumRequest, CancellationToken ct = default);
+
+ ///
+ /// This endpoint is for adding files to an album, removing files from an album, or deleting the album
+ ///
+ /// Data to pass to the API
+ /// Cancellation Token
+ /// when is null
+ /// when is null, empty, or whitespace
+ /// when is null, empty, or whitespace
+ /// when is not valid for this requestBase type
+ /// when is not
+ /// ,
+ /// ,
+ /// or
+ ///
+ /// when something bad happens when talking to the API
+ /// Response string from the API
+ ///
+ /// The ModifyAlbumAsync method only supports the following requestBase types / verbs:
+ ///
+ ///
+ /// .
+ /// Use to edit an album
+ Task ModifyAlbumAsync(ModifyAlbumImagesRequest modifyAlbumImagesRequest, CancellationToken ct = default);
+}
+
+public sealed class CatBoxClient : ICatBoxClient
+{
+ private const long MaxFileSize = 209_715_200L; // 200MB in bytes
+
+ private readonly HttpClient _client;
+ private readonly CatboxOptions _catboxOptions;
+
+ ///
+ /// Creates a new
+ ///
+ ///
+ ///
+ /// cannot be null
+ /// cannot be null
+ /// "CatBox API URL cannot be null. Check that URL was set by calling:
.AddCatBoxServices(f => f.CatBoxUrl = new Uri("https://catbox.moe/user/api.php"));
+ public CatBoxClient(HttpClient client, IOptions catboxOptions)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(catboxOptions?.Value?.CatBoxUrl);
+
+ _client = client;
+ _catboxOptions = catboxOptions!.Value!;
+ }
+
+ ///
+ public async IAsyncEnumerable UploadFilesAsync(FileUploadRequest fileUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(fileUploadRequest);
+
+ foreach (var imageFile in fileUploadRequest.Files.Where(IsFileExtensionValid))
+ {
+ ct.ThrowIfCancellationRequested();
+ await using var fileStream = File.OpenRead(imageFile.FullName);
+
+ Throw.IfCatBoxFileSizeExceeds(fileStream.Length, MaxFileSize);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.UploadFile), RequestParameters.Request },
+ { new StreamContent(fileStream), RequestParameters.FileToUpload, imageFile.Name }
+ };
+
+ if (!string.IsNullOrWhiteSpace(fileUploadRequest.UserHash))
+ content.Add(new StringContent(fileUploadRequest.UserHash), RequestParameters.UserHash);
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ yield return await response.Content.ReadAsStringAsync(ct);
+ }
+ }
+
+ ///
+ public async IAsyncEnumerable UploadFilesAsStreamAsync(IEnumerable fileUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(fileUploadRequest);
+
+ foreach (var uploadRequest in fileUploadRequest)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(uploadRequest.FileName);
+
+ if (uploadRequest.Stream.CanSeek)
+ Throw.IfCatBoxFileSizeExceeds(uploadRequest.Stream.Length, MaxFileSize);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.UploadFile), RequestParameters.Request },
+ { new StreamContent(uploadRequest.Stream), RequestParameters.FileToUpload, uploadRequest.FileName }
+ };
+
+ if (!string.IsNullOrWhiteSpace(uploadRequest.UserHash))
+ content.Add(new StringContent(uploadRequest.UserHash), RequestParameters.UserHash);
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ yield return await response.Content.ReadAsStringAsync(ct);
+ }
+ }
+
+ ///
+ public async IAsyncEnumerable UploadFilesAsUrlAsync(UrlUploadRequest urlUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(urlUploadRequest);
+
+ foreach (var fileUrl in urlUploadRequest.Files.Where(f => f is not null))
+ {
+ using var content = new MultipartFormDataContent // Disposing of MultipartFormDataContent, cascades disposal of String / Stream / Content classes
+ {
+ { new StringContent(RequestType.UrlUpload), RequestParameters.Request },
+ { new StringContent(fileUrl.AbsoluteUri), RequestParameters.Url }
+ };
+
+ if (!string.IsNullOrWhiteSpace(urlUploadRequest.UserHash))
+ content.Add(new StringContent(urlUploadRequest.UserHash), RequestParameters.UserHash);
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ yield return await response.Content.ReadAsStringAsync(ct);
+ }
+ }
+
+ ///
+ public async Task DeleteMultipleFilesAsync(DeleteFileRequest deleteFileRequest, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(deleteFileRequest);
+ ArgumentException.ThrowIfNullOrWhiteSpace(deleteFileRequest.UserHash);
+
+ var fileNames = string.Join(" ", deleteFileRequest.FileNames);
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileNames);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.DeleteFile), RequestParameters.Request },
+ { new StringContent(deleteFileRequest.UserHash), RequestParameters.UserHash },
+ { new StringContent(fileNames), RequestParameters.Files }
+ };
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+
+ ///
+ public async Task CreateAlbumAsync(RemoteCreateAlbumRequest remoteCreateAlbumRequest, CancellationToken ct = default)
+ {
+ ThrowIfAlbumCreationRequestIsInvalid(remoteCreateAlbumRequest);
+
+ var links = remoteCreateAlbumRequest.Files.Select(link =>
+ {
+ if (link?.Contains(_catboxOptions.CatBoxUrl!.Host) is true)
+ {
+ return new Uri(link).PathAndQuery[1..];
+ }
+
+ return link;
+ });
+
+ var fileNames = string.Join(" ", links);
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileNames);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.CreateAlbum), RequestParameters.Request },
+ { new StringContent(remoteCreateAlbumRequest.Title), RequestParameters.Title },
+ { new StringContent(fileNames), RequestParameters.Files }
+ };
+
+ if (!string.IsNullOrWhiteSpace(remoteCreateAlbumRequest.UserHash))
+ content.Add(new StringContent(remoteCreateAlbumRequest.UserHash), RequestParameters.UserHash);
+
+ if (!string.IsNullOrWhiteSpace(remoteCreateAlbumRequest.Description))
+ content.Add(new StringContent(remoteCreateAlbumRequest.Description), RequestParameters.Description);
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+
+ ///
+#pragma warning disable CS0618 // API is not Obsolete, but should warn the user of dangerous functionality
+ public async Task EditAlbumAsync(EditAlbumRequest editAlbumRequest, CancellationToken ct = default)
+#pragma warning restore CS0618 // API is not Obsolete, but should warn the user of dangerous functionality
+ {
+ ArgumentNullException.ThrowIfNull(editAlbumRequest);
+ ArgumentException.ThrowIfNullOrWhiteSpace(editAlbumRequest.UserHash);
+ ArgumentException.ThrowIfNullOrWhiteSpace(editAlbumRequest.Description);
+ ArgumentException.ThrowIfNullOrWhiteSpace(editAlbumRequest.Title);
+ ArgumentException.ThrowIfNullOrWhiteSpace(editAlbumRequest.AlbumId);
+
+ var fileNames = string.Join(" ", editAlbumRequest.Files);
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileNames);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.EditAlbum), RequestParameters.Request },
+ { new StringContent(editAlbumRequest.UserHash), RequestParameters.UserHash },
+ { new StringContent(editAlbumRequest.AlbumId), RequestParameters.AlbumIdShort },
+ { new StringContent(editAlbumRequest.Title), RequestParameters.Title },
+ { new StringContent(editAlbumRequest.Description), RequestParameters.Description },
+ { new StringContent(fileNames), RequestParameters.Files }
+ };
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+
+ ///
+ public async Task ModifyAlbumAsync(ModifyAlbumImagesRequest modifyAlbumImagesRequest, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(modifyAlbumImagesRequest);
+ ArgumentException.ThrowIfNullOrWhiteSpace(modifyAlbumImagesRequest.UserHash);
+
+ Throw.IfAlbumRequestTypeInvalid(IsAlbumRequestTypeValid(modifyAlbumImagesRequest), nameof(modifyAlbumImagesRequest.Request));
+ Throw.IfAlbumOperationInvalid(modifyAlbumImagesRequest.Request, RequestType.AddToAlbum, RequestType.RemoveFromAlbum, RequestType.DeleteAlbum);
+
+ var fileNames = string.Join(" ", modifyAlbumImagesRequest.Files);
+
+ using var content = new MultipartFormDataContent
+ {
+ { new StringContent(modifyAlbumImagesRequest.Request), RequestParameters.Request },
+ { new StringContent(modifyAlbumImagesRequest.UserHash), RequestParameters.UserHash },
+ { new StringContent(modifyAlbumImagesRequest.AlbumId), RequestParameters.AlbumIdShort }
+ };
+
+ if (modifyAlbumImagesRequest.Request == RequestType.AddToAlbum ||
+ modifyAlbumImagesRequest.Request == RequestType.RemoveFromAlbum)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileNames);
+ content.Add(new StringContent(fileNames), RequestParameters.Files);
+ }
+
+ using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct);
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/CatBoxClient.cs b/src/CatBox.NET/Client/CatBoxClient.cs
deleted file mode 100644
index e0a2749..0000000
--- a/src/CatBox.NET/Client/CatBoxClient.cs
+++ /dev/null
@@ -1,296 +0,0 @@
-using System.Runtime.CompilerServices;
-using CatBox.NET.Enums;
-using CatBox.NET.Requests;
-using Microsoft.Extensions.Options;
-using static CatBox.NET.Client.Common;
-
-namespace CatBox.NET.Client;
-
-public class CatBoxClient : ICatBoxClient
-{
- private readonly HttpClient _client;
- private readonly CatBoxConfig _config;
-
- ///
- /// Creates a new
- ///
- ///
- ///
- ///
- public CatBoxClient(HttpClient client, IOptions config)
- {
- _client = client ?? throw new ArgumentNullException(nameof(client), "HttpClient cannot be null");
-
- if (config.Value.CatBoxUrl is null)
- throw new ArgumentNullException(nameof(config.Value.CatBoxUrl), "CatBox API URL cannot be null. Check that URL was set by calling .AddCatBoxServices(f => f.CatBoxUrl = new Uri(\"https://catbox.moe/user/api.php\"))");
-
- _config = config.Value;
- }
-
- ///
- public async IAsyncEnumerable UploadMultipleImages(FileUploadRequest fileUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
- {
- if (fileUploadRequest is null)
- throw new ArgumentNullException(nameof(fileUploadRequest), "Argument cannot be null");
-
- foreach (var imageFile in fileUploadRequest.Files.Where(static f => IsFileExtensionValid(f.Extension)))
- {
- await using var fileStream = File.OpenRead(imageFile.FullName);
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(CatBoxRequestTypes.UploadFile.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StreamContent(fileStream), CatBoxRequestStrings.FileToUploadType, imageFile.Name }
- };
-
- if (!string.IsNullOrWhiteSpace(fileUploadRequest.UserHash))
- content.Add(new StringContent(fileUploadRequest.UserHash), CatBoxRequestStrings.UserHashType);
-
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- yield return await response.Content.ReadAsStringAsyncCore(ct);
- }
- }
-
- ///
- public async Task UploadImage(StreamUploadRequest fileUploadRequest, CancellationToken ct = default)
- {
- if (fileUploadRequest is null)
- throw new ArgumentNullException(nameof(fileUploadRequest), "Argument cannot be null");
-
- if (fileUploadRequest.FileName is null)
- throw new ArgumentNullException(nameof(fileUploadRequest.FileName), "Argument cannot be null");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(CatBoxRequestTypes.UploadFile.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StreamContent(fileUploadRequest.Stream), CatBoxRequestStrings.FileToUploadType, fileUploadRequest.FileName }
- };
-
- if (!string.IsNullOrWhiteSpace(fileUploadRequest.UserHash))
- content.Add(new StringContent(fileUploadRequest.UserHash), CatBoxRequestStrings.UserHashType);
-
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-
- ///
- public async IAsyncEnumerable UploadMultipleUrls(UrlUploadRequest urlUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
- {
- if (urlUploadRequest is null)
- throw new ArgumentNullException(nameof(urlUploadRequest), "Argument cannot be null");
-
- foreach (var fileUrl in urlUploadRequest.Files)
- {
- if (fileUrl is null)
- continue;
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent // Disposing of MultipartFormDataContent, cascades disposal of String / Stream / Content classes
- {
- { new StringContent(CatBoxRequestTypes.UrlUpload.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StringContent(fileUrl.AbsoluteUri), CatBoxRequestStrings.UrlType }
- };
-
- if (!string.IsNullOrWhiteSpace(urlUploadRequest.UserHash))
- content.Add(new StringContent(urlUploadRequest.UserHash), CatBoxRequestStrings.UserHashType);
-
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- yield return await response.Content.ReadAsStringAsyncCore(ct);
- }
- }
-
- ///
- public async Task DeleteMultipleFiles(DeleteFileRequest deleteFileRequest, CancellationToken ct = default)
- {
- if (deleteFileRequest is null)
- throw new ArgumentNullException(nameof(deleteFileRequest), "Argument cannot be null");
-
- if (string.IsNullOrWhiteSpace(deleteFileRequest.UserHash))
- throw new ArgumentNullException(nameof(deleteFileRequest.UserHash), "Argument cannot be null");
-
- var fileNames = string.Join(" ", deleteFileRequest.FileNames);
- if (string.IsNullOrWhiteSpace(fileNames))
- throw new ArgumentNullException(nameof(deleteFileRequest.FileNames), "File list cannot be empty");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(CatBoxRequestTypes.DeleteFile.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StringContent(deleteFileRequest.UserHash), CatBoxRequestStrings.UserHashType },
- { new StringContent(fileNames), CatBoxRequestStrings.FileType }
- };
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-
- ///
- public async Task CreateAlbum(CreateAlbumRequest createAlbumRequest, CancellationToken ct = default)
- {
- if (createAlbumRequest is null)
- throw new ArgumentNullException(nameof(createAlbumRequest), "Argument cannot be null");
-
- if (string.IsNullOrWhiteSpace(createAlbumRequest.Description))
- throw new ArgumentNullException(nameof(createAlbumRequest.Description),
- "Album description cannot be null, empty, or whitespace");
-
- if (string.IsNullOrWhiteSpace(createAlbumRequest.Title))
- throw new ArgumentNullException(nameof(createAlbumRequest.Title),
- "Album title cannot be null, empty, or whitespace");
-
- var links = createAlbumRequest.Files.Select(link =>
- {
- if (link.Contains(_config.CatBoxUrl!.Host))
- {
- return new Uri(link).PathAndQuery[1..];
- }
-
- return link;
- });
-
- var fileNames = string.Join(" ", links);
- if (string.IsNullOrWhiteSpace(fileNames))
- throw new ArgumentNullException(nameof(createAlbumRequest.Files), "File list cannot be empty");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(CatBoxRequestTypes.CreateAlbum.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StringContent(createAlbumRequest.Title), CatBoxRequestStrings.TitleType },
- { new StringContent(fileNames), CatBoxRequestStrings.FileType }
- };
-
- if (!string.IsNullOrWhiteSpace(createAlbumRequest.UserHash))
- content.Add(new StringContent(createAlbumRequest.UserHash), CatBoxRequestStrings.UserHashType);
-
- if (!string.IsNullOrWhiteSpace(createAlbumRequest.Description))
- content.Add(new StringContent(createAlbumRequest.Description), CatBoxRequestStrings.DescriptionType);
-
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-
- ///
- public async Task EditAlbum(EditAlbumRequest editAlbumRequest, CancellationToken ct = default)
- {
- if (editAlbumRequest is null)
- throw new ArgumentNullException(nameof(editAlbumRequest), "Argument cannot be null");
-
- if (string.IsNullOrWhiteSpace(editAlbumRequest.UserHash))
- throw new ArgumentNullException(nameof(editAlbumRequest.UserHash),
- "UserHash cannot be null, empty, or whitespace when attempting to modify an album");
-
- if (string.IsNullOrWhiteSpace(editAlbumRequest.Description))
- throw new ArgumentNullException(nameof(editAlbumRequest.Description),
- "Album description cannot be null, empty, or whitespace");
-
- if (string.IsNullOrWhiteSpace(editAlbumRequest.Title))
- throw new ArgumentNullException(nameof(editAlbumRequest.Title),
- "Album title cannot be null, empty, or whitespace");
-
- if (string.IsNullOrWhiteSpace(editAlbumRequest.AlbumId))
- throw new ArgumentNullException(nameof(editAlbumRequest.AlbumId),
- "AlbumId (Short) cannot be null, empty, or whitespace");
-
- var fileNames = string.Join(" ", editAlbumRequest.Files);
- if (string.IsNullOrWhiteSpace(fileNames))
- throw new ArgumentNullException(nameof(editAlbumRequest.Files), "File list cannot be empty");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(CatBoxRequestTypes.EditAlbum.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StringContent(editAlbumRequest.UserHash), CatBoxRequestStrings.UserHashType },
- { new StringContent(editAlbumRequest.AlbumId), CatBoxRequestStrings.AlbumIdShortType },
- { new StringContent(editAlbumRequest.Title), CatBoxRequestStrings.TitleType },
- { new StringContent(editAlbumRequest.Description), CatBoxRequestStrings.DescriptionType },
- { new StringContent(fileNames), CatBoxRequestStrings.FileType }
- };
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-
- ///
- public async Task ModifyAlbum(AlbumRequest albumRequest, CancellationToken ct = default)
- {
- if (albumRequest is null)
- throw new ArgumentNullException(nameof(albumRequest), "Argument cannot be null");
-
- if (IsAlbumRequestTypeValid(albumRequest))
-#pragma warning disable CA2208 // Instantiate argument exceptions correctly
- throw new ArgumentException("Invalid Request Type for album endpoint", nameof(albumRequest.Request));
-#pragma warning restore CA2208 // Instantiate argument exceptions correctly
-
- if (string.IsNullOrWhiteSpace(albumRequest.UserHash))
- throw new ArgumentNullException(nameof(albumRequest.UserHash),
- "UserHash cannot be null, empty, or whitespace when attempting to modify an album");
-
- if (albumRequest.Request != CatBoxRequestTypes.AddToAlbum &&
- albumRequest.Request != CatBoxRequestTypes.RemoveFromAlbum &&
- albumRequest.Request != CatBoxRequestTypes.DeleteAlbum)
- {
- throw new InvalidOperationException(
- "The ModifyAlbum method only supports CatBoxRequestTypes.AddToAlbum, CatBoxRequestTypes.RemoveFromAlbum, and CatBoxRequestTypes.DeleteAlbum. " +
- "Use Task EditAlbum(EditAlbumRequest? editAlbumRequest, CancellationToken ct = default) to edit an album");
- }
-
- var fileNames = string.Join(" ", albumRequest.Files);
- if (string.IsNullOrWhiteSpace(fileNames))
- throw new ArgumentNullException(nameof(albumRequest.Files), "File list cannot be empty");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.CatBoxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(albumRequest.Request.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StringContent(albumRequest.UserHash), CatBoxRequestStrings.UserHashType },
- { new StringContent(albumRequest.AlbumId), CatBoxRequestStrings.AlbumIdShortType }
- };
-
- // If request type is AddToAlbum or RemoveFromAlbum
- if (albumRequest.Request is CatBoxRequestTypes.AddToAlbum or CatBoxRequestTypes.RemoveFromAlbum)
- content.Add(new StringContent(fileNames), CatBoxRequestStrings.FileType);
-
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-
- ///
- /// 1. Filter Invalid Request Types on the Album Endpoint
- /// 2. Check that the user hash is not null, empty, or whitespace when attempting to modify or delete an album. User hash is required for those operations
- ///
- ///
- ///
- private static bool IsAlbumRequestTypeValid(AlbumRequest request)
- {
- switch (request.Request)
- {
- case CatBoxRequestTypes.CreateAlbum:
- case CatBoxRequestTypes.EditAlbum when !string.IsNullOrWhiteSpace(request.UserHash):
- case CatBoxRequestTypes.AddToAlbum when !string.IsNullOrWhiteSpace(request.UserHash):
- case CatBoxRequestTypes.RemoveFromAlbum when !string.IsNullOrWhiteSpace(request.UserHash):
- case CatBoxRequestTypes.DeleteAlbum when !string.IsNullOrWhiteSpace(request.UserHash):
- return true;
-
- case CatBoxRequestTypes.UploadFile:
- case CatBoxRequestTypes.UrlUpload:
- case CatBoxRequestTypes.DeleteFile:
- default:
- return false;
- }
- }
-}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/Common.cs b/src/CatBox.NET/Client/Common.cs
index 05ce65a..889be27 100644
--- a/src/CatBox.NET/Client/Common.cs
+++ b/src/CatBox.NET/Client/Common.cs
@@ -1,33 +1,59 @@
-namespace CatBox.NET.Client;
+using System.Diagnostics.CodeAnalysis;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+
+namespace CatBox.NET.Client;
internal static class Common
{
///
/// These file extensions are not allowed by the API, so filter them out
///
- ///
- ///
- public static bool IsFileExtensionValid(string extension)
+ /// The file to validate
+ /// if the file extension is valid; otherwise,
+ public static bool IsFileExtensionValid(FileInfo file)
{
- switch (extension)
+ var extension = file.Extension;
+ return extension switch
{
- case ".exe":
- case ".scr":
- case ".cpl":
- case var _ when extension.Contains(".doc"):
- case ".jar":
- return false;
- default:
- return true;
- }
+ ".exe" or ".scr" or ".cpl" or ".jar" => false,
+ _ when extension.Contains(".doc") => false,
+ _ => true
+ };
+ }
+
+ ///
+ /// Validates an Album Creation Request
+ ///
+ /// The album creation requestBase to validate
+ /// when the requestBase is null
+ /// when the description is null
+ /// when the title is null
+ public static void ThrowIfAlbumCreationRequestIsInvalid(AlbumCreationRequestBase requestBase)
+ {
+ ArgumentNullException.ThrowIfNull(requestBase);
+ ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Description);
+ ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Title);
}
- public static Task ReadAsStringAsyncCore(this HttpContent content, CancellationToken ct = default)
+ ///
+ /// 1. Filter Invalid Request Types on the Album Endpoint
+ /// 2. Check that the user hash is not null, empty, or whitespace when attempting to modify or delete an album. User hash is required for those operations
+ ///
+ ///
+ ///
+ public static bool IsAlbumRequestTypeValid(ModifyAlbumImagesRequest imagesRequest)
{
-#if NET5_0_OR_GREATER
- return content.ReadAsStringAsync(ct);
-#else
- return content.ReadAsStringAsync();
-#endif
+ var request = imagesRequest.Request;
+ var hasUserHash = !string.IsNullOrWhiteSpace(imagesRequest.UserHash);
+
+ if (request == RequestType.CreateAlbum)
+ return true;
+
+ return (request == RequestType.EditAlbum ||
+ request == RequestType.AddToAlbum ||
+ request == RequestType.RemoveFromAlbum ||
+ request == RequestType.DeleteAlbum) && hasUserHash;
}
-}
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/ICatBox.cs b/src/CatBox.NET/Client/ICatBox.cs
deleted file mode 100644
index 14f6d04..0000000
--- a/src/CatBox.NET/Client/ICatBox.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace CatBox.NET.Client;
-
-
-///
-/// Provides an abstraction over to group multiple tasks together
-///
-/// Not currently implemented so don't use
-public interface ICatBox
-{
- Task UploadFile();
- Task DeleteFile();
- Task CreateAlbum();
- Task EditAlbum();
- Task AddToAlbum();
- Task RemoveFromAlbum();
- Task DeleteAlbum();
-}
diff --git a/src/CatBox.NET/Client/ICatBoxClient.cs b/src/CatBox.NET/Client/ICatBoxClient.cs
deleted file mode 100644
index cc08846..0000000
--- a/src/CatBox.NET/Client/ICatBoxClient.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using CatBox.NET.Requests;
-
-namespace CatBox.NET.Client;
-
-public interface ICatBoxClient
-{
- ///
- /// Enables uploading multiple files from disk (FileStream) to the API
- ///
- ///
- /// Cancellation Token
- /// When is null
- /// Response string from the API
- IAsyncEnumerable UploadMultipleImages(FileUploadRequest fileUploadRequest, CancellationToken ct = default);
-
- ///
- /// Enables uploading multiple files by URL to the API
- ///
- /// Data to send to the API
- /// Cancellation Token
- /// When is null
- /// when something bad happens when talking to the API
- /// Response string from the API
- IAsyncEnumerable UploadMultipleUrls(UrlUploadRequest urlUploadRequest, CancellationToken ct = default);
-
- ///
- /// Deletes multiple files by API file name
- ///
- /// Files to delete from the server
- /// Cancellation Token
- /// When is null
- /// When is null
- /// When is null, empty, or whitespace
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task DeleteMultipleFiles(DeleteFileRequest deleteFileRequest, CancellationToken ct = default);
-
- ///
- /// Streams a single image to be uploaded
- ///
- ///
- /// Cancellation Token
- /// When is null
- /// When is null
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task UploadImage(StreamUploadRequest fileUploadRequest, CancellationToken ct = default);
-
- ///
- /// Creates an album on CatBox via provided file names generated by the API
- ///
- /// Data to pass to the API
- /// Cancellation Token
- /// when is null
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task CreateAlbum(CreateAlbumRequest createAlbumRequest, CancellationToken ct = default);
-
- ///
- /// Edits the content of album according to the content that is passed to the API
- ///
- /// Data to pass to the API
- /// Cancellation Token
- /// when is null
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task EditAlbum(EditAlbumRequest editAlbumRequest, CancellationToken ct = default);
-
- ///
- /// This endpoint is for adding files to an album, removing files from an album, or deleting the album
- ///
- /// Data to pass to the API
- /// Cancellation Token
- /// when
- /// when is null, empty, or whitespace
- /// when is null, empty, or whitespace
- /// when is not valid for this request type
- /// when is not CatBoxRequestTypes.AddToAlbum, CatBoxRequestTypes.RemoveFromAlbum, CatBoxRequestTypes.DeleteAlbum
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task ModifyAlbum(AlbumRequest albumRequest, CancellationToken ct = default);
-}
diff --git a/src/CatBox.NET/Client/ILitterboxClient.cs b/src/CatBox.NET/Client/ILitterboxClient.cs
deleted file mode 100644
index 1807fdd..0000000
--- a/src/CatBox.NET/Client/ILitterboxClient.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using CatBox.NET.Requests;
-
-namespace CatBox.NET.Client;
-
-public interface ILitterboxClient
-{
- ///
- /// Enables uploading multiple files from disk (FileStream) to the API
- ///
- ///
- /// Cancellation Token
- /// When is null
- /// Response string from the API
- IAsyncEnumerable UploadMultipleImages(TemporaryFileUploadRequest temporaryFileUploadRequest, CancellationToken ct = default);
-
- ///
- /// Streams a single image to be uploaded
- ///
- ///
- /// Cancellation Token
- /// When is null
- /// When is null
- /// when something bad happens when talking to the API
- /// Response string from the API
- Task UploadImage(TemporaryStreamUploadRequest temporaryStreamUploadRequest, CancellationToken ct = default);
-}
diff --git a/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs
new file mode 100644
index 0000000..a81a90a
--- /dev/null
+++ b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs
@@ -0,0 +1,99 @@
+using System.Runtime.CompilerServices;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.Litterbox;
+using Microsoft.Extensions.Options;
+using static CatBox.NET.Client.Common;
+
+namespace CatBox.NET.Client;
+
+public interface ILitterboxClient
+{
+ ///
+ /// Enables uploading multiple files from disk (FileStream) to the API
+ ///
+ ///
+ /// Cancellation Token
+ /// When is null
+ /// Response string from the API
+ IAsyncEnumerable UploadMultipleImagesAsync(TemporaryFileUploadRequest temporaryFileUploadRequest, CancellationToken ct = default);
+
+ ///
+ /// Streams a single image to be uploaded
+ ///
+ ///
+ /// Cancellation Token
+ /// When is null
+ /// When is null
+ /// when something bad happens when talking to the API
+ /// Response string from the API
+ Task UploadImageAsync(TemporaryStreamUploadRequest temporaryStreamUploadRequest, CancellationToken ct = default);
+}
+
+public sealed class LitterboxClient : ILitterboxClient
+{
+ private const long MaxFileSize = 1_073_741_824L; // 1GB in bytes
+
+ private readonly HttpClient _client;
+ private readonly CatboxOptions _catboxOptions;
+
+ ///
+ /// Creates a new
+ ///
+ ///
+ ///
+ /// when is null
+ /// /// when is null
+ /// LitterboxUrl API URL cannot be null. Check that URL was set by calling:
.AddCatBoxServices(f => f.LitterboxUrl = new Uri(\"https://litterbox.catbox.moe/resources/internals/api.php\"));
+ public LitterboxClient(HttpClient client, IOptions catboxOptions)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(catboxOptions?.Value?.LitterboxUrl);
+
+ _client = client;
+ _catboxOptions = catboxOptions!.Value!;
+ }
+
+ ///
+ public async IAsyncEnumerable UploadMultipleImagesAsync(TemporaryFileUploadRequest temporaryFileUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(temporaryFileUploadRequest);
+
+ foreach (var imageFile in temporaryFileUploadRequest.Files.Where(IsFileExtensionValid))
+ {
+ await using var fileStream = File.OpenRead(imageFile.FullName);
+
+ Throw.IfLitterboxFileSizeExceeds(fileStream.Length, MaxFileSize);
+
+ using var response = await _client.PostAsync(_catboxOptions.LitterboxUrl, new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.UploadFile), RequestParameters.Request },
+ { new StringContent(temporaryFileUploadRequest.Expiry), RequestParameters.Expiry },
+ { new StreamContent(fileStream), RequestParameters.FileToUpload, imageFile.Name }
+ }, ct);
+
+ yield return await response.Content.ReadAsStringAsync(ct);
+ }
+ }
+
+ ///
+ public async Task UploadImageAsync(TemporaryStreamUploadRequest temporaryStreamUploadRequest, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(temporaryStreamUploadRequest?.FileName);
+
+ if (temporaryStreamUploadRequest!.Stream.CanSeek)
+ Throw.IfLitterboxFileSizeExceeds(temporaryStreamUploadRequest.Stream.Length, MaxFileSize);
+
+ using var response = await _client.PostAsync(_catboxOptions.LitterboxUrl, new MultipartFormDataContent
+ {
+ { new StringContent(RequestType.UploadFile), RequestParameters.Request },
+ { new StringContent(temporaryStreamUploadRequest!.Expiry), RequestParameters.Expiry },
+ {
+ new StreamContent(temporaryStreamUploadRequest.Stream), RequestParameters.FileToUpload,
+ temporaryStreamUploadRequest.FileName
+ }
+ }, ct);
+
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/LitterboxClient.cs b/src/CatBox.NET/Client/LitterboxClient.cs
deleted file mode 100644
index c60aa8e..0000000
--- a/src/CatBox.NET/Client/LitterboxClient.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using System.Runtime.CompilerServices;
-using CatBox.NET.Enums;
-using CatBox.NET.Requests;
-using Microsoft.Extensions.Options;
-using static CatBox.NET.Client.Common;
-
-namespace CatBox.NET.Client;
-
-public class LitterboxClient : ILitterboxClient
-{
- private readonly HttpClient _client;
- private readonly CatBoxConfig _config;
-
- ///
- /// Creates a new
- ///
- ///
- ///
- ///
- public LitterboxClient(HttpClient client, IOptions config)
- {
- _client = client ?? throw new ArgumentNullException(nameof(client), "HttpClient cannot be null");
-
- if (config.Value.LitterboxUrl is null)
- throw new ArgumentNullException(nameof(config.Value.CatBoxUrl), "CatBox API URL cannot be null. Check that URL was set by calling .AddCatBoxServices(f => f.CatBoxUrl = new Uri(\"https://litterbox.catbox.moe/resources/internals/api.php\"))");
-
- _config = config.Value;
- }
-
- ///
- public async IAsyncEnumerable UploadMultipleImages(TemporaryFileUploadRequest temporaryFileUploadRequest, [EnumeratorCancellation] CancellationToken ct = default)
- {
- if (temporaryFileUploadRequest is null)
- throw new ArgumentNullException(nameof(temporaryFileUploadRequest), "Argument cannot be null");
-
- foreach (var imageFile in temporaryFileUploadRequest.Files.Where(static f => IsFileExtensionValid(f.Extension)))
- {
- await using var fileStream = File.OpenRead(imageFile.FullName);
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.LitterboxUrl);
- using var content = new MultipartFormDataContent()
- {
- { new StringContent(temporaryFileUploadRequest.Expiry.ToRequest()), CatBoxRequestStrings.ExpiryType },
- { new StringContent(CatBoxRequestTypes.UploadFile.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StreamContent(fileStream), CatBoxRequestStrings.FileToUploadType, imageFile.Name }
- };
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- yield return await response.Content.ReadAsStringAsyncCore(ct);
- }
- }
-
- ///
- public async Task UploadImage(TemporaryStreamUploadRequest temporaryStreamUploadRequest, CancellationToken ct = default)
- {
- if (temporaryStreamUploadRequest is null)
- throw new ArgumentNullException(nameof(temporaryStreamUploadRequest), "Argument cannot be null");
-
- if (temporaryStreamUploadRequest.FileName is null)
- throw new ArgumentNullException(nameof(temporaryStreamUploadRequest.FileName), "Argument cannot be null");
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _config.LitterboxUrl);
- using var content = new MultipartFormDataContent
- {
- { new StringContent(temporaryStreamUploadRequest.Expiry.ToRequest()), CatBoxRequestStrings.ExpiryType },
- { new StringContent(CatBoxRequestTypes.UploadFile.ToRequest()), CatBoxRequestStrings.RequestType },
- { new StreamContent(temporaryStreamUploadRequest.Stream), CatBoxRequestStrings.FileToUploadType, temporaryStreamUploadRequest.FileName }
- };
- request.Content = content;
-
- using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- return await response.Content.ReadAsStringAsyncCore(ct);
- }
-}
\ No newline at end of file
diff --git a/src/CatBox.NET/Client/Throw.cs b/src/CatBox.NET/Client/Throw.cs
new file mode 100644
index 0000000..c103d16
--- /dev/null
+++ b/src/CatBox.NET/Client/Throw.cs
@@ -0,0 +1,62 @@
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album.Modify;
+
+namespace CatBox.NET.Client;
+
+///
+/// Provides helper methods for throwing exceptions with consistent error handling
+///
+internal static class Throw
+{
+ ///
+ /// Throws if the file size exceeds the maximum allowed size for CatBox
+ ///
+ /// The size of the file in bytes
+ /// The maximum allowed file size in bytes
+ /// When file size exceeds the maximum
+ public static void IfCatBoxFileSizeExceeds(long fileSize, long maxSize)
+ {
+ if (fileSize > maxSize)
+ throw new Exceptions.CatBoxFileSizeLimitExceededException(fileSize);
+ }
+
+ ///
+ /// Throws if the file size exceeds the maximum allowed size for Litterbox
+ ///
+ /// The size of the file in bytes
+ /// The maximum allowed file size in bytes
+ /// When file size exceeds the maximum
+ public static void IfLitterboxFileSizeExceeds(long fileSize, long maxSize)
+ {
+ if (fileSize > maxSize)
+ throw new Exceptions.LitterboxFileSizeLimitExceededException(fileSize);
+ }
+
+ ///
+ /// Throws if the album request type is invalid
+ ///
+ /// Whether the request type is valid
+ /// The name of the parameter that is invalid
+ /// When the request type is invalid for the album endpoint
+ public static void IfAlbumRequestTypeInvalid(bool isValid, string paramName)
+ {
+ if (!isValid)
+ throw new ArgumentException("Invalid Request Type for album endpoint", paramName);
+ }
+
+ ///
+ /// Throws if the album request type is not supported for the operation
+ ///
+ /// The request type to validate
+ /// The allowed request types for this operation
+ /// When the request type is not in the allowed types
+ public static void IfAlbumOperationInvalid(RequestType request, params RequestType[] allowedTypes)
+ {
+ if (allowedTypes.Any(allowedType => request == allowedType))
+ {
+ return;
+ }
+
+ throw new InvalidOperationException("Invalid Request Type for album endpoint");
+ }
+}
diff --git a/src/CatBox.NET/Enums/CatBoxRequestStrings.cs b/src/CatBox.NET/Enums/CatBoxRequestStrings.cs
deleted file mode 100644
index 59cc997..0000000
--- a/src/CatBox.NET/Enums/CatBoxRequestStrings.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-namespace CatBox.NET.Enums;
-
-///
-/// A class for organizing request strings and arguments for the library and the API
-///
-internal static class CatBoxRequestStrings
-{
- ///
- /// Request API Argument in MultiPartForm
- ///
- public const string RequestType = "reqtype";
-
- ///
- /// UserHash API Argument in MultiPartForm
- ///
- public const string UserHashType = "userhash";
-
- ///
- /// Url API Argument in MultiPartForm
- ///
- public const string UrlType = "url";
-
- ///
- /// Files API Argument in MultiPartForm
- ///
- public const string FileType = "files";
-
- ///
- /// FileToUpload API Argument in MultiPartForm
- ///
- public const string FileToUploadType = "fileToUpload";
-
- ///
- /// Title API Argument in MultiPartForm
- ///
- public const string TitleType = "title";
-
- ///
- /// Description API Argument in MultiPartForm
- ///
- public const string DescriptionType = "desc";
-
- ///
- /// Album Id API Argument in MultiPartForm
- ///
- public const string AlbumIdShortType = "short";
-
- public const string ExpiryType = "time";
-
- private static string UploadFile => "fileupload";
- private static string UrlUpload => "urlupload";
- private static string DeleteFile => "deletefiles";
- private static string CreateAlbum => "createalbum";
- private static string EditAlbum => "editalbum";
- private static string AddToAlbum => "addtoalbum";
- private static string RemoveFromAlbum => "removefromalbum";
- private static string DeleteFromAlbum => "deletealbum";
-
- ///
- /// Converts a to the CatBox.moe equivalent API parameter string
- ///
- /// A request type
- /// CatBox API Request String
- /// when an invalid request type is chosen
- public static string ToRequest(this CatBoxRequestTypes requestTypes) =>
- requestTypes switch
- {
- CatBoxRequestTypes.UploadFile => UploadFile,
- CatBoxRequestTypes.UrlUpload => UrlUpload,
- CatBoxRequestTypes.DeleteFile => DeleteFile,
- CatBoxRequestTypes.CreateAlbum => CreateAlbum,
- CatBoxRequestTypes.EditAlbum => EditAlbum,
- CatBoxRequestTypes.AddToAlbum => AddToAlbum,
- CatBoxRequestTypes.RemoveFromAlbum => RemoveFromAlbum,
- CatBoxRequestTypes.DeleteAlbum => DeleteFromAlbum,
- _ => throw new ArgumentOutOfRangeException(nameof(requestTypes), requestTypes, null)
- };
-
- ///
- /// Converts a value to the Litterbox.moe API equivalent time string
- ///
- /// Amount of time before an image expires and is deleted
- /// Litterbox API Time Equivalent parameter value
- /// when an invalid expiry value is chosen
- public static string ToRequest(this ExpireAfter expiry) =>
- expiry switch
- {
- ExpireAfter.OneHour => "1h",
- ExpireAfter.TwelveHours => "12h",
- ExpireAfter.OneDay => "24h",
- ExpireAfter.ThreeDays => "72h",
- _ => throw new ArgumentOutOfRangeException(nameof(expiry), expiry, null)
- };
-}
diff --git a/src/CatBox.NET/Enums/CatBoxRequestTypes.cs b/src/CatBox.NET/Enums/CatBoxRequestTypes.cs
deleted file mode 100644
index 5d2c18e..0000000
--- a/src/CatBox.NET/Enums/CatBoxRequestTypes.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-namespace CatBox.NET.Enums;
-
-///
-/// Types used for CatBox
-///
-public enum CatBoxRequestTypes
-{
- ///
- /// UploadFile => "fileupload"
- ///
- UploadFile,
-
- ///
- /// UrlUpload => "urlupload"
- ///
- UrlUpload,
-
- ///
- /// DeleteFile => "deletefiles"
- ///
- DeleteFile,
-
- ///
- /// CreateAlbum => "createalbum"
- ///
- CreateAlbum,
-
- ///
- /// EditAlbum => "editalbum"
- ///
- EditAlbum,
-
- ///
- /// AddToAlbum => "addtoalbum"
- ///
- AddToAlbum,
-
- ///
- /// RemoveFromAlbum => "removefromalbum"
- ///
- RemoveFromAlbum,
-
- ///
- /// DeleteFromAlbum => "deletealbum"
- ///
- DeleteAlbum
-}
diff --git a/src/CatBox.NET/Enums/ExpireAfter.cs b/src/CatBox.NET/Enums/ExpireAfter.cs
index 4aeafd6..d88ef33 100644
--- a/src/CatBox.NET/Enums/ExpireAfter.cs
+++ b/src/CatBox.NET/Enums/ExpireAfter.cs
@@ -1,27 +1,13 @@
-namespace CatBox.NET.Enums;
+using Intellenum;
+
+namespace CatBox.NET.Enums;
///
/// Image expiry in litterbox.moe
///
-public enum ExpireAfter
-{
- ///
- /// Expire after 1 hour
- ///
- OneHour,
-
- ///
- /// Expire after 12 hours
- ///
- TwelveHours,
-
- ///
- /// Expire after one day (24 hours)
- ///
- OneDay,
-
- ///
- /// Expire after three days (72 hours)
- ///
- ThreeDays
-}
+[Intellenum(Conversions.TypeConverter)]
+[Member("OneHour", "1h")]
+[Member("TwelveHours", "12h")]
+[Member("OneDay", "24h")]
+[Member("ThreeDays", "72h")]
+public sealed partial class ExpireAfter;
diff --git a/src/CatBox.NET/Enums/RequestParameters.cs b/src/CatBox.NET/Enums/RequestParameters.cs
new file mode 100644
index 0000000..598b243
--- /dev/null
+++ b/src/CatBox.NET/Enums/RequestParameters.cs
@@ -0,0 +1,18 @@
+using Intellenum;
+
+namespace CatBox.NET.Enums;
+
+///
+/// API Request parameters for requestBase content and requestBase types
+///
+[Intellenum(Conversions.TypeConverter)]
+[Member("Request", "reqtype")]
+[Member("UserHash", "userhash")]
+[Member("Url", "url")]
+[Member("Files", "files")]
+[Member("FileToUpload", "fileToUpload")]
+[Member("Title", "title")]
+[Member("Description", "desc")]
+[Member("AlbumIdShort", "short")]
+[Member("Expiry", "time")]
+internal sealed partial class RequestParameters;
\ No newline at end of file
diff --git a/src/CatBox.NET/Enums/RequestType.cs b/src/CatBox.NET/Enums/RequestType.cs
new file mode 100644
index 0000000..84f554a
--- /dev/null
+++ b/src/CatBox.NET/Enums/RequestType.cs
@@ -0,0 +1,17 @@
+using Intellenum;
+
+namespace CatBox.NET.Enums;
+
+///
+/// Types used for CatBox
+///
+[Intellenum(Conversions.TypeConverter)]
+[Member("UploadFile", "fileupload")]
+[Member("UrlUpload", "urlupload")]
+[Member("DeleteFile", "deletefiles")]
+[Member("CreateAlbum", "createalbum")]
+[Member("EditAlbum", "editalbum")]
+[Member("AddToAlbum", "addtoalbum")]
+[Member("RemoveFromAlbum", "removefromalbum")]
+[Member("DeleteAlbum", "deletealbum")]
+public sealed partial class RequestType;
diff --git a/src/CatBox.NET/Enums/UploadHost.cs b/src/CatBox.NET/Enums/UploadHost.cs
deleted file mode 100644
index 36439b3..0000000
--- a/src/CatBox.NET/Enums/UploadHost.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace CatBox.NET.Enums;
-
-///
-/// Currently unused
-///
-public enum UploadHost
-{
- CatBox,
- LitterBox
-}
diff --git a/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs
new file mode 100644
index 0000000..71f2a30
--- /dev/null
+++ b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs
@@ -0,0 +1,82 @@
+using System.Diagnostics;
+using System.Net;
+using CatBox.NET.Client;
+using CatBox.NET.Logging;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace CatBox.NET.Exceptions;
+
+internal sealed class CatBoxFileNotFoundException : Exception
+{
+ public override string Message { get; } = "The CatBox File was not found";
+}
+
+internal sealed class CatBoxAlbumNotFoundException : Exception
+{
+ public override string Message { get; } = "The CatBox Album was not found";
+}
+
+// API Response Message: No requestBase type given.
+internal sealed class CatBoxMissingRequestTypeException : Exception
+{
+ public override string Message { get; } = "The CatBox Request Type was not specified. Did you miss an API parameter?";
+}
+
+// API Response Message: No files given.
+internal sealed class CatBoxMissingFileException : Exception
+{
+ public override string Message { get; } = "The FileToUpload parameter was not specified or is missing content. Did you miss an API parameter?";
+}
+
+//API Response Message: No expire time specified.
+internal sealed class LitterboxInvalidExpiry : Exception
+{
+ public override string Message { get; } = "The Litterbox expiry requestBase parameter is invalid. Valid expiration times are: 1h, 12h, 24h, 72h";
+}
+
+// File size exceeds Litterbox's 1 GB upload limit
+internal sealed class LitterboxFileSizeLimitExceededException(long fileSize) : Exception
+{
+ public override string Message { get; } = $"File size exceeds Litterbox's 1 GB upload limit. File size: {fileSize:N0} bytes ({fileSize / 1024.0 / 1024.0 / 1024.0:F2} GB)";
+}
+
+// File size exceeds CatBox's 200 MB upload limit
+internal sealed class CatBoxFileSizeLimitExceededException(long fileSize) : Exception
+{
+ public override string Message { get; } = $"File size exceeds CatBox's 200 MB upload limit. File size: {fileSize:N0} bytes ({fileSize / 1024.0 / 1024.0:F2} MB)";
+}
+
+internal sealed class ExceptionHandler(ILogger? logger = null) : DelegatingHandler
+{
+ private const string FileNotFound = "File doesn't exist?";
+ private const string AlbumNotFound = "No album found for user specified.";
+ private const string MissingRequestType = "No requestBase type given.";
+ private const string MissingFileParameter = "No files given.";
+ private const string InvalidExpiry = "No expire time specified.";
+
+ private readonly ILogger _logger = logger ?? NullLogger.Instance;
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var response = await base.SendAsync(request, cancellationToken);
+ if (response.StatusCode != HttpStatusCode.PreconditionFailed)
+ return response;
+
+ var content = response.Content;
+ var apiErrorMessage = await content.ReadAsStringAsync(cancellationToken);
+ _logger.LogCatBoxAPIException(response.StatusCode, apiErrorMessage);
+
+ throw apiErrorMessage switch
+ {
+ AlbumNotFound => new CatBoxAlbumNotFoundException(),
+ FileNotFound => new CatBoxFileNotFoundException(),
+ InvalidExpiry => new LitterboxInvalidExpiry(),
+ MissingFileParameter => new CatBoxMissingFileException(),
+ MissingRequestType => new CatBoxMissingRequestTypeException(),
+ _ when response.StatusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Request Failure: {apiErrorMessage}"),
+ _ when response.StatusCode >= HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Internal Server Error: {apiErrorMessage}"),
+ _ => new UnreachableException($"I don't know how you got here, but please create an issue on our GitHub (https://github.com/ChaseDRedmon/CatBox.NET): {apiErrorMessage}")
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Logging/LoggerExtensions.cs b/src/CatBox.NET/Logging/LoggerExtensions.cs
new file mode 100644
index 0000000..ae821c5
--- /dev/null
+++ b/src/CatBox.NET/Logging/LoggerExtensions.cs
@@ -0,0 +1,10 @@
+using System.Net;
+using Microsoft.Extensions.Logging;
+
+namespace CatBox.NET.Logging;
+
+public static partial class LoggerExtensions
+{
+ [LoggerMessage(EventId = 1000, Level = LogLevel.Error, Message = "HttpStatus: {StatusCode} - {Message}")]
+ public static partial void LogCatBoxAPIException(this ILogger logger, HttpStatusCode statusCode, string message);
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Properties/AssemblyInfo.cs b/src/CatBox.NET/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..57d48c3
--- /dev/null
+++ b/src/CatBox.NET/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CatBox.Tests")]
diff --git a/src/CatBox.NET/Requests/CreateAlbumRequest.cs b/src/CatBox.NET/Requests/Album/Create/AlbumCreationRequestBase.cs
similarity index 56%
rename from src/CatBox.NET/Requests/CreateAlbumRequest.cs
rename to src/CatBox.NET/Requests/Album/Create/AlbumCreationRequestBase.cs
index 21405a1..ab51860 100644
--- a/src/CatBox.NET/Requests/CreateAlbumRequest.cs
+++ b/src/CatBox.NET/Requests/Album/Create/AlbumCreationRequestBase.cs
@@ -1,15 +1,10 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.Album.Create;
///
-/// Wraps a request to create a new album and add files to it
+/// The necessary data structure to create an album
///
-public record CreateAlbumRequest
+public abstract record AlbumCreationRequestBase
{
- ///
- /// UserHash code to associate the album with
- ///
- public string? UserHash { get; init; }
-
///
/// The title of the album
///
@@ -19,9 +14,9 @@ public record CreateAlbumRequest
/// An optional description for the album
///
public string? Description { get; init; }
-
+
///
- /// A collection of already uploaded file URLs to put together in the album
+ /// UserHash code to associate the album with
///
- public required IEnumerable Files { get; init; }
-}
+ public string? UserHash { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Create/CreateAlbumRequest.cs b/src/CatBox.NET/Requests/Album/Create/CreateAlbumRequest.cs
new file mode 100644
index 0000000..7f05221
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Create/CreateAlbumRequest.cs
@@ -0,0 +1,13 @@
+using AnyOfTypes;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+
+namespace CatBox.NET.Requests.Album.Create;
+
+///
+///
+///
+public sealed record CreateAlbumRequest : AlbumCreationRequestBase, IAlbumUploadRequest
+{
+ public required AnyOf, UrlUploadRequest> UploadRequest { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Create/LocalCreateAlbumRequest.cs b/src/CatBox.NET/Requests/Album/Create/LocalCreateAlbumRequest.cs
new file mode 100644
index 0000000..70278fc
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Create/LocalCreateAlbumRequest.cs
@@ -0,0 +1,12 @@
+namespace CatBox.NET.Requests.Album.Create;
+
+///
+/// Wraps a request to upload files to the API and creates an album from those uploaded files
+///
+public sealed record LocalCreateAlbumRequest : AlbumCreationRequestBase
+{
+ ///
+ /// The files to upload to CatBox
+ ///
+ public required IAsyncEnumerable Files { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Create/RemoteCreateAlbumRequest.cs b/src/CatBox.NET/Requests/Album/Create/RemoteCreateAlbumRequest.cs
new file mode 100644
index 0000000..9409321
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Create/RemoteCreateAlbumRequest.cs
@@ -0,0 +1,12 @@
+namespace CatBox.NET.Requests.Album.Create;
+
+///
+/// Wraps a request to create a new album with files that have been uploaded to the API already
+///
+public sealed record RemoteCreateAlbumRequest : AlbumCreationRequestBase
+{
+ ///
+ /// A collection of already uploaded file URLs to put together in the album
+ ///
+ public required IEnumerable Files { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/EditAlbumRequest.cs b/src/CatBox.NET/Requests/Album/EditAlbumRequest.cs
similarity index 61%
rename from src/CatBox.NET/Requests/EditAlbumRequest.cs
rename to src/CatBox.NET/Requests/Album/EditAlbumRequest.cs
index ec76d3d..63e4da9 100644
--- a/src/CatBox.NET/Requests/EditAlbumRequest.cs
+++ b/src/CatBox.NET/Requests/Album/EditAlbumRequest.cs
@@ -1,9 +1,13 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.Album;
///
-/// Wraps a request to edit an existing album with new files, new title, new description
+/// Wraps a requestBase to edit an existing album with new files, new title, new description
///
-public record EditAlbumRequest
+///
+/// This sets command sets the album to mirror the content in this requestBase
+///
+[Obsolete("Warning! This is a Powerful and Dangerous command. You can irreversibly destroy albums with this command if you do not understand how this command works!")]
+public sealed record EditAlbumRequest
{
///
/// The UserHash that owns the album
@@ -29,4 +33,4 @@ public record EditAlbumRequest
/// The collection of files to associate together for the album
///
public required IEnumerable Files { get; init; }
-}
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/IAlbumUploadRequest.cs b/src/CatBox.NET/Requests/Album/IAlbumUploadRequest.cs
new file mode 100644
index 0000000..86db5fa
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/IAlbumUploadRequest.cs
@@ -0,0 +1,16 @@
+using AnyOfTypes;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+
+namespace CatBox.NET.Requests.Album;
+
+///
+/// Represents an upload requestBase
+///
+public interface IAlbumUploadRequest
+{
+ ///
+ /// The upload requestBase
+ ///
+ AnyOf, UrlUploadRequest> UploadRequest { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Modify/AlbumBase.cs b/src/CatBox.NET/Requests/Album/Modify/AlbumBase.cs
new file mode 100644
index 0000000..d81f548
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Modify/AlbumBase.cs
@@ -0,0 +1,24 @@
+using CatBox.NET.Enums;
+
+namespace CatBox.NET.Requests.Album.Modify;
+
+///
+/// An abstract request representing parameters needed to work with the Album API
+///
+public abstract record AlbumBase
+{
+ ///
+ ///
+ ///
+ public required RequestType Request { get; init; }
+
+ ///
+ /// The User who owns this album
+ ///
+ public required string UserHash { get; init; }
+
+ ///
+ /// The unique identifier for the album (API value: "short")
+ ///
+ public required string AlbumId { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs
new file mode 100644
index 0000000..f1f3a33
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs
@@ -0,0 +1,13 @@
+namespace CatBox.NET.Requests.Album.Modify;
+
+///
+/// Wraps a requestBase to add files, remove files, or delete an album
+///
+public sealed record ModifyAlbumImagesRequest : AlbumBase
+{
+ ///
+ /// The list of files associated with the album
+ ///
+ /// may alter the significance of this collection
+ public required IEnumerable Files { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Album/Modify/UploadToAlbumRequest.cs b/src/CatBox.NET/Requests/Album/Modify/UploadToAlbumRequest.cs
new file mode 100644
index 0000000..a953b96
--- /dev/null
+++ b/src/CatBox.NET/Requests/Album/Modify/UploadToAlbumRequest.cs
@@ -0,0 +1,16 @@
+using AnyOfTypes;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+
+namespace CatBox.NET.Requests.Album.Modify;
+
+///
+/// A request for uploading files into an existing CatBox Album
+///
+public sealed record UploadToAlbumRequest : AlbumBase, IAlbumUploadRequest
+{
+ ///
+ /// The CatBox Upload request to upload files into an existing album
+ ///
+ public required AnyOf, UrlUploadRequest> UploadRequest { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/AlbumRequest.cs b/src/CatBox.NET/Requests/AlbumRequest.cs
deleted file mode 100644
index 9a38e42..0000000
--- a/src/CatBox.NET/Requests/AlbumRequest.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using CatBox.NET.Enums;
-
-namespace CatBox.NET.Requests;
-
-///
-/// Wraps a request to add files, remove files, or delete an album
-///
-public record AlbumRequest
-{
- ///
- ///
- ///
- public required CatBoxRequestTypes Request { get; init; }
-
- ///
- /// The User who owns this album
- ///
- public required string UserHash { get; init; }
-
- ///
- /// The unique identifier for the album
- ///
- public required string AlbumId { get; init; }
-
- ///
- /// The list of files associated with the album
- ///
- /// may alter the significance of this collection
- public required IEnumerable Files { get; init; }
-}
diff --git a/src/CatBox.NET/Requests/DeleteFileRequest.cs b/src/CatBox.NET/Requests/File/DeleteFileRequest.cs
similarity index 60%
rename from src/CatBox.NET/Requests/DeleteFileRequest.cs
rename to src/CatBox.NET/Requests/File/DeleteFileRequest.cs
index 7ade1c9..0fa41ed 100644
--- a/src/CatBox.NET/Requests/DeleteFileRequest.cs
+++ b/src/CatBox.NET/Requests/File/DeleteFileRequest.cs
@@ -1,9 +1,9 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.File;
///
-/// Wraps a request to delete files from the API
+/// Wraps a requestBase to delete files from the API
///
-public record DeleteFileRequest
+public sealed record DeleteFileRequest
{
///
/// The UserHash that owns the associated files
@@ -11,7 +11,7 @@ public record DeleteFileRequest
public required string UserHash { get; init; }
///
- /// The URLs of the files to delete
+ /// The file names of the files to delete
///
public required IEnumerable FileNames { get; init; }
-}
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/FileUploadRequest.cs b/src/CatBox.NET/Requests/File/FileUploadRequest.cs
similarity index 55%
rename from src/CatBox.NET/Requests/FileUploadRequest.cs
rename to src/CatBox.NET/Requests/File/FileUploadRequest.cs
index d1faef5..c8f0890 100644
--- a/src/CatBox.NET/Requests/FileUploadRequest.cs
+++ b/src/CatBox.NET/Requests/File/FileUploadRequest.cs
@@ -1,12 +1,12 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.File;
///
/// Wraps multiple files to upload to the API
///
-public record FileUploadRequest : UploadRequest
+public sealed record FileUploadRequest : UploadRequestBase
{
///
- /// A collection of file streams to upload
+ /// A collection of files paths to upload
///
public required IEnumerable Files { get; init; }
-}
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/StreamUploadRequest.cs b/src/CatBox.NET/Requests/File/StreamUploadRequest.cs
similarity index 77%
rename from src/CatBox.NET/Requests/StreamUploadRequest.cs
rename to src/CatBox.NET/Requests/File/StreamUploadRequest.cs
index 6fc08a9..5b7bbd5 100644
--- a/src/CatBox.NET/Requests/StreamUploadRequest.cs
+++ b/src/CatBox.NET/Requests/File/StreamUploadRequest.cs
@@ -1,9 +1,9 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.File;
///
/// Wraps a network stream to stream content to the API
///
-public record StreamUploadRequest : UploadRequest
+public sealed record StreamUploadRequest : UploadRequestBase
{
///
/// The name of the file
@@ -14,4 +14,4 @@ public record StreamUploadRequest : UploadRequest
/// The byte stream that contains the image data
///
public required Stream Stream { get; init; }
-}
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/UploadRequest.cs b/src/CatBox.NET/Requests/File/UploadRequestBase.cs
similarity index 75%
rename from src/CatBox.NET/Requests/UploadRequest.cs
rename to src/CatBox.NET/Requests/File/UploadRequestBase.cs
index dd3cdba..0748dd2 100644
--- a/src/CatBox.NET/Requests/UploadRequest.cs
+++ b/src/CatBox.NET/Requests/File/UploadRequestBase.cs
@@ -1,9 +1,9 @@
-namespace CatBox.NET.Requests;
+namespace CatBox.NET.Requests.File;
///
/// A base record for all file upload requests where the UserHash is optional
///
-public abstract record UploadRequest
+public abstract record UploadRequestBase
{
///
/// The UserHash associated with this file upload
diff --git a/src/CatBox.NET/Requests/Litterbox/TemporaryFileUploadRequest.cs b/src/CatBox.NET/Requests/Litterbox/TemporaryFileUploadRequest.cs
new file mode 100644
index 0000000..837e019
--- /dev/null
+++ b/src/CatBox.NET/Requests/Litterbox/TemporaryFileUploadRequest.cs
@@ -0,0 +1,12 @@
+namespace CatBox.NET.Requests.Litterbox;
+
+///
+/// A temporary requestBase for a collection of one or more files
+///
+public sealed record TemporaryFileUploadRequest : TemporaryRequestBase
+{
+ ///
+ /// A collection of files to upload
+ ///
+ public required IEnumerable Files { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Litterbox/TemporaryRequestBase.cs b/src/CatBox.NET/Requests/Litterbox/TemporaryRequestBase.cs
new file mode 100644
index 0000000..e992ad1
--- /dev/null
+++ b/src/CatBox.NET/Requests/Litterbox/TemporaryRequestBase.cs
@@ -0,0 +1,11 @@
+using CatBox.NET.Enums;
+
+namespace CatBox.NET.Requests.Litterbox;
+
+public abstract record TemporaryRequestBase
+{
+ ///
+ /// When the image, or images, should be expired
+ ///
+ public required ExpireAfter Expiry { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/Litterbox/TemporaryStreamUploadRequest.cs b/src/CatBox.NET/Requests/Litterbox/TemporaryStreamUploadRequest.cs
new file mode 100644
index 0000000..3aa8096
--- /dev/null
+++ b/src/CatBox.NET/Requests/Litterbox/TemporaryStreamUploadRequest.cs
@@ -0,0 +1,17 @@
+namespace CatBox.NET.Requests.Litterbox;
+
+///
+/// A temporary requestBase for an individual file upload
+///
+public sealed record TemporaryStreamUploadRequest : TemporaryRequestBase
+{
+ ///
+ /// The name of the file
+ ///
+ public required string FileName { get; init; }
+
+ ///
+ /// The byte stream that contains the image data
+ ///
+ public required Stream Stream { get; init; }
+}
\ No newline at end of file
diff --git a/src/CatBox.NET/Requests/TemporaryFileUploadRequest.cs b/src/CatBox.NET/Requests/TemporaryFileUploadRequest.cs
deleted file mode 100644
index 345cc79..0000000
--- a/src/CatBox.NET/Requests/TemporaryFileUploadRequest.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using CatBox.NET.Enums;
-
-namespace CatBox.NET.Requests;
-
-///
-/// A temporary request for a collection of one or more files
-///
-public record TemporaryFileUploadRequest
-{
- ///
- /// When the image, or images, should be expired
- ///
- public required ExpireAfter Expiry { get; init; }
-
- ///
- /// A collection of files to upload
- ///
- public required IEnumerable Files { get; init; }
-}
diff --git a/src/CatBox.NET/Requests/TemporaryStreamUploadRequest.cs b/src/CatBox.NET/Requests/TemporaryStreamUploadRequest.cs
deleted file mode 100644
index 320ec7d..0000000
--- a/src/CatBox.NET/Requests/TemporaryStreamUploadRequest.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using CatBox.NET.Enums;
-
-namespace CatBox.NET.Requests;
-
-///
-/// A temporary request for an individual file upload
-///
-public record TemporaryStreamUploadRequest
-{
- ///
- /// When the image should be expired
- ///
- public required ExpireAfter Expiry { get; init; }
-
- ///
- /// The name of the file
- ///
- public required string FileName { get; init; }
-
- ///
- /// The byte stream that contains the image data
- ///
- public required Stream Stream { get; init; }
-}
diff --git a/src/CatBox.NET/Requests/UrlUploadRequest.cs b/src/CatBox.NET/Requests/URL/UrlUploadRequest.cs
similarity index 61%
rename from src/CatBox.NET/Requests/UrlUploadRequest.cs
rename to src/CatBox.NET/Requests/URL/UrlUploadRequest.cs
index 03de501..5f2c2a7 100644
--- a/src/CatBox.NET/Requests/UrlUploadRequest.cs
+++ b/src/CatBox.NET/Requests/URL/UrlUploadRequest.cs
@@ -1,12 +1,14 @@
-namespace CatBox.NET.Requests;
+using CatBox.NET.Requests.File;
+
+namespace CatBox.NET.Requests.URL;
///
/// Wraps multiple URLs to upload to the API
///
-public record UrlUploadRequest : UploadRequest
+public sealed record UrlUploadRequest : UploadRequestBase
{
///
/// A collection of URLs to upload
///
public required IEnumerable Files { get; init; }
-}
+}
\ No newline at end of file
diff --git a/tests/CatBox.Tests/CatBox.Tests.csproj b/tests/CatBox.Tests/CatBox.Tests.csproj
index c6b7249..0921c23 100644
--- a/tests/CatBox.Tests/CatBox.Tests.csproj
+++ b/tests/CatBox.Tests/CatBox.Tests.csproj
@@ -1,20 +1,42 @@
-
- net7.0
- enable
- enable
- false
- true
- 10
-
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ latest
+ f7c8b9e3-4a5d-4e2f-9b3c-1d8e7f6a5b4c
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
diff --git a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs
new file mode 100644
index 0000000..a323e4b
--- /dev/null
+++ b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs
@@ -0,0 +1,376 @@
+using CatBox.NET;
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+using CatBox.Tests.Helpers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Shouldly;
+
+namespace CatBox.Tests;
+
+///
+/// Integration tests for CatBoxClient that make real API calls
+/// Requires CATBOX_USER_HASH environment variable to be set
+/// Run with: dotnet test --filter Category=Integration
+///
+[TestFixture]
+[Category("Integration")]
+public class CatBoxClientIntegrationTests
+{
+ private CatBoxClient? _client;
+ private static readonly List _uploadedFiles = new();
+ private static readonly List _createdAlbums = new();
+ private static readonly Lock _lock = new();
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ if (!IntegrationTestConfig.IsConfigured)
+ {
+ Assert.Ignore("Integration tests skipped: CatBox:UserHash not configured. " +
+ "Set via: dotnet user-secrets set \"CatBox:UserHash\" \"your-hash\" " +
+ "or environment variable: CATBOX_USER_HASH=your-hash");
+ }
+
+ // Use DI container to properly configure the client with resilience handlers
+ var services = new ServiceCollection();
+ services.AddCatBoxServices(options =>
+ {
+ options.CatBoxUrl = IntegrationTestConfig.CatBoxUrl;
+ });
+
+ var serviceProvider = services.BuildServiceProvider();
+ _client = serviceProvider.GetRequiredService() as CatBoxClient;
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ if (_client == null || !IntegrationTestConfig.IsConfigured)
+ return;
+
+ try
+ {
+ // Delete albums first (they reference files)
+ if (_createdAlbums.Count > 0)
+ {
+ TestContext.WriteLine($"Cleaning up {_createdAlbums.Count} album(s)...");
+ foreach (var albumId in _createdAlbums)
+ {
+ try
+ {
+ var deleteAlbumRequest = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.DeleteAlbum,
+ UserHash = IntegrationTestConfig.UserHash!,
+ AlbumId = albumId,
+ Files = []
+ };
+ await _client.ModifyAlbumAsync(deleteAlbumRequest);
+ TestContext.WriteLine($"Deleted album: {albumId}");
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Failed to delete album {albumId}: {ex.Message}");
+ }
+ }
+ }
+
+ // Then delete individual files
+ if (_uploadedFiles.Count > 0)
+ {
+ TestContext.WriteLine($"Cleaning up {_uploadedFiles.Count} file(s)...");
+ var deleteRequest = new DeleteFileRequest
+ {
+ UserHash = IntegrationTestConfig.UserHash!,
+ FileNames = _uploadedFiles.ToList()
+ };
+
+ var result = await _client.DeleteMultipleFilesAsync(deleteRequest);
+ TestContext.WriteLine($"Delete result: {result}");
+ }
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Cleanup error: {ex.Message}");
+ }
+ }
+
+ private void TrackUploadedFile(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ return;
+
+ // Extract filename from URL (e.g., "abc123.png" from "https://files.catbox.moe/abc123.png")
+ var fileName = new Uri(url).Segments.Last();
+ using (_lock.EnterScope())
+ {
+ if (!_uploadedFiles.Contains(fileName))
+ {
+ _uploadedFiles.Add(fileName);
+ TestContext.WriteLine($"Tracked file for cleanup: {fileName}");
+ }
+ }
+ }
+
+ private void TrackCreatedAlbum(string? albumUrl)
+ {
+ if (string.IsNullOrWhiteSpace(albumUrl))
+ return;
+
+ // Extract album ID from URL (e.g., "abc123" from "https://catbox.moe/c/abc123")
+ var albumId = new Uri(albumUrl).Segments.Last();
+ using (_lock.EnterScope())
+ {
+ if (!_createdAlbums.Contains(albumId))
+ {
+ _createdAlbums.Add(albumId);
+ TestContext.WriteLine($"Tracked album for cleanup: {albumId}");
+ }
+ }
+ }
+
+ [Test]
+ [Order(1)]
+ public async Task UploadFilesAsync_WithFileFromDisk_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ File.Exists(testFilePath).ShouldBeTrue($"Test file not found: {testFilePath}");
+
+ var request = new FileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ UserHash = IntegrationTestConfig.UserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ TrackUploadedFile(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://files.catbox.moe/");
+ TestContext.WriteLine($"Uploaded file URL: {results[0]}");
+ }
+
+ [Test]
+ [Order(2)]
+ public async Task UploadFilesAsStreamAsync_WithMemoryStream_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var fileBytes = await File.ReadAllBytesAsync(testFilePath);
+ var stream = new MemoryStream(fileBytes);
+
+ var requests = new[]
+ {
+ new StreamUploadRequest
+ {
+ FileName = "test-stream.png",
+ Stream = stream,
+ UserHash = IntegrationTestConfig.UserHash
+ }
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadFilesAsStreamAsync(requests))
+ {
+ results.Add(result);
+ TrackUploadedFile(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://files.catbox.moe/");
+ TestContext.WriteLine($"Uploaded stream URL: {results[0]}");
+ }
+
+ [Test]
+ [Order(3)]
+ public async Task UploadFilesAsUrlAsync_WithPublicUrl_Succeeds()
+ {
+ // Arrange - Using a public SVG from Wikipedia
+ var request = new UrlUploadRequest
+ {
+ Files = [new Uri("https://upload.wikimedia.org/wikipedia/commons/6/6b/Bitmap_VS_SVG.svg")],
+ UserHash = IntegrationTestConfig.UserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadFilesAsUrlAsync(request))
+ {
+ results.Add(result);
+ TrackUploadedFile(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://files.catbox.moe/");
+ TestContext.WriteLine($"Uploaded from URL: {results[0]}");
+ }
+
+ [Test]
+ [Order(4)]
+ public async Task CreateAlbumAsync_WithUploadedFiles_Succeeds()
+ {
+ // Arrange - Upload two files first
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var uploadRequest = new FileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath), new FileInfo(testFilePath)],
+ UserHash = IntegrationTestConfig.UserHash
+ };
+
+ var uploadedFileUrls = new List();
+ await foreach (var url in _client!.UploadFilesAsync(uploadRequest))
+ {
+ uploadedFileUrls.Add(url);
+ TrackUploadedFile(url);
+ }
+
+ // Extract filenames from URLs
+ var fileNames = uploadedFileUrls
+ .Where(url => !string.IsNullOrWhiteSpace(url))
+ .Select(url => new Uri(url!).Segments.Last())
+ .ToList();
+
+ var albumRequest = new RemoteCreateAlbumRequest
+ {
+ Title = "CatBox.NET Integration Test Album",
+ Description = "Test album created by integration tests",
+ UserHash = IntegrationTestConfig.UserHash,
+ Files = fileNames
+ };
+
+ // Act
+ var albumUrl = await _client.CreateAlbumAsync(albumRequest);
+ TrackCreatedAlbum(albumUrl);
+
+ // Assert
+ albumUrl.ShouldNotBeNullOrWhiteSpace();
+ albumUrl.ShouldStartWith("https://catbox.moe/c/");
+ TestContext.WriteLine($"Created album: {albumUrl}");
+ }
+
+ [Test]
+ [Order(5)]
+ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds()
+ {
+ // Arrange - Create an album with one file
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var uploadRequest = new FileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ UserHash = IntegrationTestConfig.UserHash
+ };
+
+ string? uploadedUrl = null;
+ await foreach (var url in _client!.UploadFilesAsync(uploadRequest))
+ {
+ uploadedUrl = url;
+ break;
+ }
+ TrackUploadedFile(uploadedUrl);
+ var fileName = new Uri(uploadedUrl!).Segments.Last();
+
+ var createAlbumRequest = new RemoteCreateAlbumRequest
+ {
+ Title = "CatBox.NET Modify Test Album",
+ Description = "Testing album modification",
+ UserHash = IntegrationTestConfig.UserHash,
+ Files = [fileName]
+ };
+
+ var albumUrl = await _client.CreateAlbumAsync(createAlbumRequest);
+ TrackCreatedAlbum(albumUrl);
+ var albumId = new Uri(albumUrl!).Segments.Last();
+
+ // Upload another file to add to the album
+ string? secondUploadUrl = null;
+ await foreach (var url in _client.UploadFilesAsync(uploadRequest))
+ {
+ secondUploadUrl = url;
+ break;
+ }
+ TrackUploadedFile(secondUploadUrl);
+ var secondFileName = new Uri(secondUploadUrl!).Segments.Last();
+
+ // Act - Add file to album
+ var addRequest = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = IntegrationTestConfig.UserHash!,
+ AlbumId = albumId,
+ Files = [secondFileName]
+ };
+
+ var addResult = await _client.ModifyAlbumAsync(addRequest);
+ TestContext.WriteLine($"Add to album result: {addResult}");
+
+ // Act - Remove file from album
+ var removeRequest = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.RemoveFromAlbum,
+ UserHash = IntegrationTestConfig.UserHash!,
+ AlbumId = albumId,
+ Files = [secondFileName]
+ };
+
+ var removeResult = await _client.ModifyAlbumAsync(removeRequest);
+ TestContext.WriteLine($"Remove from album result: {removeResult}");
+
+ // Assert
+ addResult.ShouldNotBeNullOrWhiteSpace();
+ removeResult.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Test]
+ [Order(6)]
+ public async Task DeleteMultipleFilesAsync_WithUploadedFiles_Succeeds()
+ {
+ // Arrange - Upload a file specifically for deletion test
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var uploadRequest = new FileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ UserHash = IntegrationTestConfig.UserHash
+ };
+
+ string? uploadedUrl = null;
+ await foreach (var url in _client!.UploadFilesAsync(uploadRequest))
+ {
+ uploadedUrl = url;
+ break;
+ }
+ uploadedUrl.ShouldNotBeNullOrWhiteSpace();
+ var fileName = new Uri(uploadedUrl!).Segments.Last();
+
+ var deleteRequest = new DeleteFileRequest
+ {
+ UserHash = IntegrationTestConfig.UserHash!,
+ FileNames = [fileName]
+ };
+
+ // Act
+ var result = await _client.DeleteMultipleFilesAsync(deleteRequest);
+
+ // Assert
+ result.ShouldNotBeNullOrWhiteSpace();
+ TestContext.WriteLine($"Delete result: {result}");
+ }
+}
diff --git a/tests/CatBox.Tests/CatBoxClientTests.cs b/tests/CatBox.Tests/CatBoxClientTests.cs
index d911d2c..6e1bd09 100644
--- a/tests/CatBox.Tests/CatBoxClientTests.cs
+++ b/tests/CatBox.Tests/CatBoxClientTests.cs
@@ -1,15 +1,1232 @@
+using CatBox.NET;
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+using CatBox.NET.Requests.File;
+using CatBox.NET.Requests.URL;
+using CatBox.Tests.Helpers;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
namespace CatBox.Tests;
-public class Tests
+[TestFixture]
+public class CatBoxClientTests
{
- [SetUp]
- public void Setup()
+ private const string TestCatBoxUrl = "https://catbox.moe/user/api.php";
+ private const string TestUserHash = "test-user-hash";
+ private const string TestFileUrl = "https://files.catbox.moe/abc123.jpg";
+
+ private string _tempTestJpg = null!;
+ private string _tempTestPng = null!;
+ private string _tempTest1Jpg = null!;
+ private string _tempTest2Png = null!;
+ private string _tempTest3Gif = null!;
+ private string _tempMalwareExe = null!;
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ // Create temporary test files with minimal valid headers
+ _tempTestJpg = Path.Combine(Path.GetTempPath(), $"catbox_test_{Guid.NewGuid()}.jpg");
+ _tempTestPng = Path.Combine(Path.GetTempPath(), $"catbox_test_{Guid.NewGuid()}.png");
+ _tempTest1Jpg = Path.Combine(Path.GetTempPath(), $"catbox_test1_{Guid.NewGuid()}.jpg");
+ _tempTest2Png = Path.Combine(Path.GetTempPath(), $"catbox_test2_{Guid.NewGuid()}.png");
+ _tempTest3Gif = Path.Combine(Path.GetTempPath(), $"catbox_test3_{Guid.NewGuid()}.gif");
+ _tempMalwareExe = Path.Combine(Path.GetTempPath(), $"catbox_malware_{Guid.NewGuid()}.exe");
+
+ // JPEG header
+ File.WriteAllBytes(_tempTestJpg, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
+ File.WriteAllBytes(_tempTest1Jpg, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
+
+ // PNG header
+ File.WriteAllBytes(_tempTestPng, new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
+ File.WriteAllBytes(_tempTest2Png, new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
+
+ // GIF header
+ File.WriteAllBytes(_tempTest3Gif, new byte[] { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 });
+
+ // EXE header
+ File.WriteAllBytes(_tempMalwareExe, new byte[] { 0x4D, 0x5A, 0x90, 0x00 });
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ // Clean up temporary test files
+ TryDeleteFile(_tempTestJpg);
+ TryDeleteFile(_tempTestPng);
+ TryDeleteFile(_tempTest1Jpg);
+ TryDeleteFile(_tempTest2Png);
+ TryDeleteFile(_tempTest3Gif);
+ TryDeleteFile(_tempMalwareExe);
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ private IOptions CreateOptions()
+ {
+ var options = new CatboxOptions
+ {
+ CatBoxUrl = new Uri(TestCatBoxUrl)
+ };
+ return Options.Create(options);
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_WithValidRequestAndUserHash_ReturnsExpectedUrls()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new FileUploadRequest
+ {
+ Files = [testFile],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_WithoutUserHash_SucceedsAnonymously()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestPng);
+ var request = new FileUploadRequest
+ {
+ Files = [testFile],
+ UserHash = null
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_WithInvalidFileExtension_FiltersOutInvalidFiles()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var validFile = new FileInfo(_tempTestJpg);
+ var invalidFile = new FileInfo(_tempMalwareExe);
+ var request = new FileUploadRequest
+ {
+ Files = [validFile, invalidFile],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ // Only the valid file should be uploaded
+ results.Count.ShouldBe(1);
+ }
+
+ [Test]
+ public void UploadFilesAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsync(null!))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_WithMultipleFiles_YieldsMultipleResponses()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var file1 = new FileInfo(_tempTest1Jpg);
+ var file2 = new FileInfo(_tempTest2Png);
+ var file3 = new FileInfo(_tempTest3Gif);
+ var request = new FileUploadRequest
+ {
+ Files = [file1, file2, file3],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(3);
+ results.ShouldAllBe(r => r == TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_WithEmptyFileCollection_YieldsNothing()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new FileUploadRequest
+ {
+ Files = [],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.ShouldBeEmpty();
+ }
+
+ [Test]
+ public async Task UploadFilesAsync_CancellationToken_CancelsOperation()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new FileUploadRequest
+ {
+ Files = [testFile],
+ UserHash = TestUserHash
+ };
+
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsync(request, cts.Token))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadFilesAsStreamAsync_WithValidRequestAndUserHash_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var requests = new[]
+ {
+ new StreamUploadRequest
+ {
+ FileName = "test.jpg",
+ Stream = stream,
+ UserHash = TestUserHash
+ }
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsStreamAsync(requests))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsStreamAsync_WithoutUserHash_SucceedsAnonymously()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var requests = new[]
+ {
+ new StreamUploadRequest
+ {
+ FileName = "test.jpg",
+ Stream = stream,
+ UserHash = null
+ }
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsStreamAsync(requests))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public void UploadFilesAsStreamAsync_WithNullFileName_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var requests = new[]
+ {
+ new StreamUploadRequest
+ {
+ FileName = null!,
+ Stream = stream,
+ UserHash = TestUserHash
+ }
+ };
+
+ // Act & Assert
+ Should.Throw(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsStreamAsync(requests))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadFilesAsStreamAsync_WithMultipleStreams_ProcessesAll()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var requests = new[]
+ {
+ new StreamUploadRequest
+ {
+ FileName = "test1.jpg",
+ Stream = new MemoryStream([1, 2, 3]),
+ UserHash = TestUserHash
+ },
+ new StreamUploadRequest
+ {
+ FileName = "test2.png",
+ Stream = new MemoryStream([4, 5, 6]),
+ UserHash = TestUserHash
+ }
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsStreamAsync(requests))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(2);
+ results.ShouldAllBe(r => r == TestFileUrl);
+ }
+
+ [Test]
+ public void UploadFilesAsStreamAsync_WithNullRequest_ThrowsArgumentNullException()
{
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsStreamAsync(null!))
+ {
+ }
+ });
}
[Test]
- public void Test1()
+ public async Task UploadFilesAsUrlAsync_WithValidUrls_Succeeds()
{
- Assert.Pass();
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new UrlUploadRequest
+ {
+ Files = [new Uri("https://example.com/image.jpg")],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsUrlAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsUrlAsync_WithoutUserHash_SucceedsAnonymously()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new UrlUploadRequest
+ {
+ Files = [new Uri("https://example.com/image.jpg")],
+ UserHash = null
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsUrlAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestFileUrl);
+ }
+
+ [Test]
+ public async Task UploadFilesAsUrlAsync_WithMultipleUrls_ProcessesAll()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new UrlUploadRequest
+ {
+ Files =
+ [
+ new Uri("https://example.com/image1.jpg"),
+ new Uri("https://example.com/image2.png"),
+ new Uri("https://example.com/image3.gif")
+ ],
+ UserHash = TestUserHash
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadFilesAsUrlAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(3);
+ results.ShouldAllBe(r => r == TestFileUrl);
+ }
+
+ [Test]
+ public void UploadFilesAsUrlAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsUrlAsync(null!))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadFilesAsUrlAsync_CancellationToken_CancelsOperation()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new UrlUploadRequest
+ {
+ Files = [new Uri("https://example.com/image.jpg")],
+ UserHash = TestUserHash
+ };
+
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () =>
+ {
+ await foreach (var _ in client.UploadFilesAsUrlAsync(request, cts.Token))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task DeleteMultipleFilesAsync_WithValidRequest_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Files deleted successfully");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new DeleteFileRequest
+ {
+ UserHash = TestUserHash,
+ FileNames = ["file1.jpg", "file2.png"]
+ };
+
+ // Act
+ var result = await client.DeleteMultipleFilesAsync(request);
+
+ // Assert
+ result.ShouldBe("Files deleted successfully");
+ }
+
+ [Test]
+ public void DeleteMultipleFilesAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () => await client.DeleteMultipleFilesAsync(null!));
+ }
+
+ [Test]
+ public void DeleteMultipleFilesAsync_WithNullUserHash_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new DeleteFileRequest
+ {
+ UserHash = null!,
+ FileNames = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.DeleteMultipleFilesAsync(request));
+ }
+
+ [Test]
+ public void DeleteMultipleFilesAsync_WithEmptyUserHash_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new DeleteFileRequest
+ {
+ UserHash = "",
+ FileNames = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.DeleteMultipleFilesAsync(request));
+ }
+
+ [Test]
+ public void DeleteMultipleFilesAsync_WithEmptyFileNames_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new DeleteFileRequest
+ {
+ UserHash = TestUserHash,
+ FileNames = []
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.DeleteMultipleFilesAsync(request));
+ }
+
+ [Test]
+ public async Task DeleteMultipleFilesAsync_WithMultipleFiles_JoinsWithSpace()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new DeleteFileRequest
+ {
+ UserHash = TestUserHash,
+ FileNames = ["file1.jpg", "file2.png", "file3.gif"]
+ };
+
+ // Act
+ var result = await client.DeleteMultipleFilesAsync(request);
+
+ // Assert
+ result.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task CreateAlbumAsync_WithValidRequest_ReturnsAlbumUrl()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("https://catbox.moe/c/abc123");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Test Description",
+ UserHash = TestUserHash,
+ Files = ["file1.jpg", "file2.png"]
+ };
+
+ // Act
+ var result = await client.CreateAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("https://catbox.moe/c/abc123");
+ }
+
+ [Test]
+ public async Task CreateAlbumAsync_WithFullUrls_StripsHostname()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("https://catbox.moe/c/abc123");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Test Description",
+ UserHash = TestUserHash,
+ Files = ["https://files.catbox.moe/file1.jpg", "https://files.catbox.moe/file2.png"]
+ };
+
+ // Act
+ var result = await client.CreateAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("https://catbox.moe/c/abc123");
+ }
+
+ [Test]
+ public async Task CreateAlbumAsync_WithShortFilenames_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("https://catbox.moe/c/abc123");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Test Description",
+ UserHash = TestUserHash,
+ Files = ["abc123.jpg", "def456.png"]
+ };
+
+ // Act
+ var result = await client.CreateAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("https://catbox.moe/c/abc123");
+ }
+
+ [Test]
+ public void CreateAlbumAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () => await client.CreateAlbumAsync(null!));
+ }
+
+ [Test]
+ public void CreateAlbumAsync_WithNullTitle_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = null!,
+ Description = "Test Description",
+ UserHash = TestUserHash,
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.CreateAlbumAsync(request));
+ }
+
+ [Test]
+ public void CreateAlbumAsync_WithNullDescription_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = null!,
+ UserHash = TestUserHash,
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.CreateAlbumAsync(request));
+ }
+
+ [Test]
+ public async Task CreateAlbumAsync_WithoutUserHash_CreatesAnonymousAlbum()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("https://catbox.moe/c/abc123");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Test Description",
+ UserHash = null,
+ Files = ["file1.jpg"]
+ };
+
+ // Act
+ var result = await client.CreateAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("https://catbox.moe/c/abc123");
+ }
+
+ [Test]
+ public void CreateAlbumAsync_WithEmptyFiles_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Test Description",
+ UserHash = TestUserHash,
+ Files = []
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.CreateAlbumAsync(request));
+ }
+
+ [Test]
+ public async Task CreateAlbumAsync_WithOptionalDescription_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("https://catbox.moe/c/abc123");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Album",
+ Description = "Optional Description",
+ UserHash = TestUserHash,
+ Files = ["file1.jpg"]
+ };
+
+ // Act
+ var result = await client.CreateAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("https://catbox.moe/c/abc123");
+ }
+
+ [Test]
+ public async Task EditAlbumAsync_WithValidRequest_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Album edited successfully");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Title = "Updated Title",
+ Description = "Updated Description",
+ Files = ["file1.jpg", "file2.png"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act
+ var result = await client.EditAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Album edited successfully");
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithNullUserHash_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = null!,
+ AlbumId = "abc123",
+ Title = "Title",
+ Description = "Description",
+ Files = ["file1.jpg"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(request));
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithNullTitle_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Title = null!,
+ Description = "Description",
+ Files = ["file1.jpg"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(request));
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithNullDescription_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Title = "Title",
+ Description = null!,
+ Files = ["file1.jpg"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(request));
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithNullAlbumId_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = null!,
+ Title = "Title",
+ Description = "Description",
+ Files = ["file1.jpg"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(request));
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithEmptyFiles_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Title = "Title",
+ Description = "Description",
+ Files = []
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(request));
+ }
+
+ [Test]
+ public void EditAlbumAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () => await client.EditAlbumAsync(null!));
+ }
+
+ [Test]
+ public async Task EditAlbumAsync_WithAllParameters_SendsCorrectly()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+#pragma warning disable CS0618 // Type or member is obsolete
+ var request = new EditAlbumRequest
+ {
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Title = "New Title",
+ Description = "New Description",
+ Files = ["file1.jpg", "file2.png", "file3.gif"]
+ };
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ // Act
+ var result = await client.EditAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Success");
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_AddToAlbum_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Files added successfully");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg", "file2.png"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Files added successfully");
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_RemoveFromAlbum_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Files removed successfully");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.RemoveFromAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Files removed successfully");
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_DeleteAlbum_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Album deleted successfully");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.DeleteAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = []
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Album deleted successfully");
+ }
+
+ [Test]
+ public void ModifyAlbumAsync_WithInvalidRequestType_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.UploadFile,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.ModifyAlbumAsync(request));
+ }
+
+ [Test]
+ public void ModifyAlbumAsync_WithNullUserHash_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = null!,
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.ModifyAlbumAsync(request));
+ }
+
+ [Test]
+ public void ModifyAlbumAsync_WithEmptyFiles_ThrowsArgumentException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = []
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.ModifyAlbumAsync(request));
+ }
+
+ [Test]
+ public void ModifyAlbumAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () => await client.ModifyAlbumAsync(null!));
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_AddToAlbum_IncludesFilesParameter()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg", "file2.png"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_RemoveFromAlbum_IncludesFilesParameter()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.RemoveFromAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldNotBeNull();
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_DeleteAlbum_DoesNotRequireFiles()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Album deleted");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.DeleteAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["ignored.jpg"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldBe("Album deleted");
+ }
+
+ [Test]
+ public void ModifyAlbumAsync_WithWrongRequestType_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.CreateAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.ModifyAlbumAsync(request));
+ }
+
+ [Test]
+ public async Task ModifyAlbumAsync_WithMultipleFiles_JoinsWithSpace()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient("Success");
+ var client = new CatBoxClient(httpClient, CreateOptions());
+
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = RequestType.AddToAlbum,
+ UserHash = TestUserHash,
+ AlbumId = "abc123",
+ Files = ["file1.jpg", "file2.png", "file3.gif"]
+ };
+
+ // Act
+ var result = await client.ModifyAlbumAsync(request);
+
+ // Assert
+ result.ShouldNotBeNull();
+ }
+
+ [Test]
+ public void Constructor_WithNullHttpClient_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() => new CatBoxClient(null!, CreateOptions()));
+ }
+
+ [Test]
+ public void Constructor_WithNullCatBoxUrl_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestFileUrl);
+ var options = Options.Create(new CatboxOptions { CatBoxUrl = null });
+
+ // Act & Assert
+ Should.Throw(() => new CatBoxClient(httpClient, options));
}
-}
\ No newline at end of file
+}
diff --git a/tests/CatBox.Tests/CommonTests.cs b/tests/CatBox.Tests/CommonTests.cs
new file mode 100644
index 0000000..1951ab5
--- /dev/null
+++ b/tests/CatBox.Tests/CommonTests.cs
@@ -0,0 +1,218 @@
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Album.Create;
+using CatBox.NET.Requests.Album.Modify;
+using NUnit.Framework;
+using Shouldly;
+
+namespace CatBox.Tests;
+
+[TestFixture]
+public class CommonTests
+{
+ [TestCase(".exe", "malware.exe")]
+ [TestCase(".scr", "screensaver.scr")]
+ [TestCase(".cpl", "control.cpl")]
+ [TestCase(".jar", "application.jar")]
+ [TestCase(".doc", "document.doc")]
+ [TestCase(".docx", "document.docx")]
+ public void IsFileExtensionValid_WithInvalidExtensions_ReturnsFalse(string extension, string filename)
+ {
+ // Arrange
+ var file = new FileInfo(filename);
+
+ // Act
+ var result = Common.IsFileExtensionValid(file);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+
+ [TestCase(".jpg", "image.jpg")]
+ [TestCase(".png", "image.png")]
+ [TestCase(".gif", "animation.gif")]
+ [TestCase(".mp4", "video.mp4")]
+ public void IsFileExtensionValid_WithValidExtensions_ReturnsTrue(string extension, string filename)
+ {
+ // Arrange
+ var file = new FileInfo(filename);
+
+ // Act
+ var result = Common.IsFileExtensionValid(file);
+
+ // Assert
+ result.ShouldBeTrue();
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(null!));
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithNullTitle_ThrowsArgumentException()
+ {
+ // Arrange
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = null!,
+ Description = "Test Description",
+ UserHash = "test-hash",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request));
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithWhitespaceTitle_ThrowsArgumentException()
+ {
+ // Arrange
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = " ",
+ Description = "Test Description",
+ UserHash = "test-hash",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request));
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithNullDescription_ThrowsArgumentException()
+ {
+ // Arrange
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Title",
+ Description = null!,
+ UserHash = "test-hash",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request));
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithWhitespaceDescription_ThrowsArgumentException()
+ {
+ // Arrange
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Title",
+ Description = " ",
+ UserHash = "test-hash",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request));
+ }
+
+ [Test]
+ public void ThrowIfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow()
+ {
+ // Arrange
+ var request = new RemoteCreateAlbumRequest
+ {
+ Title = "Test Title",
+ Description = "Test Description",
+ UserHash = "test-hash",
+ Files = ["file1.jpg"]
+ };
+
+ // Act & Assert
+ Should.NotThrow(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request));
+ }
+
+ private static IEnumerable ValidAlbumRequestCases()
+ {
+ yield return new TestCaseData(RequestType.CreateAlbum, "test-hash").SetName("CreateAlbum with UserHash");
+ yield return new TestCaseData(RequestType.CreateAlbum, "").SetName("CreateAlbum without UserHash");
+ yield return new TestCaseData(RequestType.EditAlbum, "test-hash").SetName("EditAlbum with UserHash");
+ yield return new TestCaseData(RequestType.AddToAlbum, "test-hash").SetName("AddToAlbum with UserHash");
+ yield return new TestCaseData(RequestType.RemoveFromAlbum, "test-hash").SetName("RemoveFromAlbum with UserHash");
+ yield return new TestCaseData(RequestType.DeleteAlbum, "test-hash").SetName("DeleteAlbum with UserHash");
+ }
+
+ [TestCaseSource(nameof(ValidAlbumRequestCases))]
+ public void IsAlbumRequestTypeValid_WithValidRequestTypeAndRequiredUserHash_ReturnsTrue(RequestType requestType, string userHash)
+ {
+ // Arrange
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = requestType,
+ UserHash = userHash,
+ AlbumId = "abc123",
+ Files = requestType == RequestType.DeleteAlbum ? Array.Empty() : new[] { "file1.jpg" }
+ };
+
+ // Act
+ var result = Common.IsAlbumRequestTypeValid(request);
+
+ // Assert
+ result.ShouldBeTrue();
+ }
+
+ private static IEnumerable InvalidAlbumRequestMissingUserHashCases()
+ {
+ yield return new TestCaseData(RequestType.EditAlbum, "").SetName("EditAlbum with empty UserHash");
+ yield return new TestCaseData(RequestType.EditAlbum, null).SetName("EditAlbum with null UserHash");
+ yield return new TestCaseData(RequestType.AddToAlbum, "").SetName("AddToAlbum with empty UserHash");
+ yield return new TestCaseData(RequestType.AddToAlbum, null).SetName("AddToAlbum with null UserHash");
+ yield return new TestCaseData(RequestType.RemoveFromAlbum, "").SetName("RemoveFromAlbum with empty UserHash");
+ yield return new TestCaseData(RequestType.RemoveFromAlbum, null).SetName("RemoveFromAlbum with null UserHash");
+ yield return new TestCaseData(RequestType.DeleteAlbum, "").SetName("DeleteAlbum with empty UserHash");
+ yield return new TestCaseData(RequestType.DeleteAlbum, null).SetName("DeleteAlbum with null UserHash");
+ }
+
+ [TestCaseSource(nameof(InvalidAlbumRequestMissingUserHashCases))]
+ public void IsAlbumRequestTypeValid_WithRequiredUserHashMissing_ReturnsFalse(RequestType requestType, string? userHash)
+ {
+ // Arrange
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = requestType,
+ UserHash = userHash!,
+ AlbumId = "abc123",
+ Files = requestType == RequestType.DeleteAlbum ? Array.Empty() : new[] { "file1.jpg" }
+ };
+
+ // Act
+ var result = Common.IsAlbumRequestTypeValid(request);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+
+ private static IEnumerable InvalidRequestTypeCases()
+ {
+ yield return new TestCaseData(RequestType.UploadFile).SetName("UploadFile RequestType");
+ yield return new TestCaseData(RequestType.DeleteFile).SetName("DeleteFile RequestType");
+ }
+
+ [TestCaseSource(nameof(InvalidRequestTypeCases))]
+ public void IsAlbumRequestTypeValid_WithInvalidRequestType_ReturnsFalse(RequestType requestType)
+ {
+ // Arrange
+ var request = new ModifyAlbumImagesRequest
+ {
+ Request = requestType,
+ UserHash = "test-hash",
+ AlbumId = "abc123",
+ Files = ["file1.jpg"]
+ };
+
+ // Act
+ var result = Common.IsAlbumRequestTypeValid(request);
+
+ // Assert
+ result.ShouldBeFalse();
+ }
+}
diff --git a/tests/CatBox.Tests/Helpers/HttpClientTestHelper.cs b/tests/CatBox.Tests/Helpers/HttpClientTestHelper.cs
new file mode 100644
index 0000000..eb55a3e
--- /dev/null
+++ b/tests/CatBox.Tests/Helpers/HttpClientTestHelper.cs
@@ -0,0 +1,88 @@
+using System.Net;
+using NSubstitute;
+
+namespace CatBox.Tests.Helpers;
+
+///
+/// Helper class for creating HttpClient instances with mocked responses for testing
+///
+public static class HttpClientTestHelper
+{
+ ///
+ /// Creates an HttpClient with a mocked handler that returns the specified response
+ ///
+ /// The content to return in the response
+ /// The HTTP status code to return (default: OK)
+ /// A configured HttpClient with mocked responses
+ public static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK)
+ {
+ var mockHandler = Substitute.ForPartsOf();
+
+ mockHandler.PublicSendAsync(Arg.Any(), Arg.Any())
+ .Returns(_ => Task.FromResult(new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(responseContent)
+ }));
+
+ return new HttpClient(mockHandler);
+ }
+
+ ///
+ /// Creates an HttpClient with a mocked handler that throws an exception
+ ///
+ /// The exception to throw
+ /// A configured HttpClient that throws the specified exception
+ public static HttpClient CreateMockHttpClientWithException(Exception exception)
+ {
+ var mockHandler = Substitute.ForPartsOf();
+
+ mockHandler.PublicSendAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ => throw exception);
+
+ return new HttpClient(mockHandler);
+ }
+
+ ///
+ /// Creates an HttpClient with a mocked handler that captures the requestBase for inspection
+ ///
+ /// The content to return in the response
+ /// Out parameter that will contain the captured requestBase
+ /// The HTTP status code to return (default: OK)
+ /// A configured HttpClient with mocked responses
+ public static HttpClient CreateMockHttpClientWithRequestCapture(
+ string responseContent,
+ out HttpRequestMessage? capturedRequest,
+ HttpStatusCode statusCode = HttpStatusCode.OK)
+ {
+ HttpRequestMessage? localCapturedRequest = null;
+ var mockHandler = Substitute.ForPartsOf();
+
+ mockHandler.PublicSendAsync(Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ localCapturedRequest = callInfo.Arg();
+ var response = new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(responseContent)
+ };
+ return Task.FromResult(response);
+ });
+
+ capturedRequest = localCapturedRequest;
+ return new HttpClient(mockHandler);
+ }
+}
+
+///
+/// Mockable wrapper for HttpMessageHandler that exposes the protected SendAsync method
+/// This is necessary because NSubstitute cannot mock protected members directly
+///
+public abstract class MockableHttpMessageHandler : HttpMessageHandler
+{
+ public abstract Task PublicSendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return PublicSendAsync(request, cancellationToken);
+ }
+}
diff --git a/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs b/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs
new file mode 100644
index 0000000..cdf3a33
--- /dev/null
+++ b/tests/CatBox.Tests/Helpers/IntegrationTestConfig.cs
@@ -0,0 +1,58 @@
+using Microsoft.Extensions.Configuration;
+
+namespace CatBox.Tests.Helpers;
+
+///
+/// Configuration helper for integration tests that make real API calls
+/// Supports both User Secrets (for local development) and Environment Variables (for CI/CD)
+///
+public static class IntegrationTestConfig
+{
+ ///
+ /// Lazy-initialized configuration that reads from User Secrets and Environment Variables
+ /// User Secrets override Environment Variables when both are present
+ ///
+ private static readonly Lazy _configuration = new(() =>
+ {
+ return new ConfigurationBuilder()
+ .AddEnvironmentVariables() // Base layer - for CI/CD pipelines
+ .AddUserSecrets(typeof(IntegrationTestConfig).Assembly) // Override layer - for local development
+ .Build();
+ });
+
+ ///
+ /// Gets the CatBox user hash from configuration
+ /// Required for integration tests to enable file/album deletion
+ /// Checks multiple sources in priority order:
+ /// 1. User Secrets: "CatBox:UserHash" (preferred for local development)
+ /// 2. Environment Variable: "CATBOX_USER_HASH" (backward compatibility and CI/CD)
+ /// 3. Environment Variable: "CatBox__UserHash" (hierarchical format)
+ ///
+ public static string? UserHash =>
+ _configuration.Value["CatBox:UserHash"] ??
+ _configuration.Value["CATBOX_USER_HASH"];
+
+ ///
+ /// Gets whether integration tests should run (UserHash is configured)
+ ///
+ public static bool IsConfigured => !string.IsNullOrWhiteSpace(UserHash);
+
+ ///
+ /// Real CatBox API endpoint for integration tests
+ ///
+ public static Uri CatBoxUrl => new("https://catbox.moe/user/api.php");
+
+ ///
+ /// Real Litterbox API endpoint for integration tests
+ ///
+ public static Uri LitterboxUrl => new("https://litterbox.catbox.moe/resources/internals/api.php");
+
+ ///
+ /// Gets the path to the test PNG file
+ ///
+ public static string GetTestFilePath()
+ {
+ var testDirectory = NUnit.Framework.TestContext.CurrentContext.TestDirectory;
+ return Path.Combine(testDirectory, "Images", "test-file.png");
+ }
+}
diff --git a/tests/CatBox.Tests/Images/test-file.png b/tests/CatBox.Tests/Images/test-file.png
new file mode 100644
index 0000000..8c58faa
Binary files /dev/null and b/tests/CatBox.Tests/Images/test-file.png differ
diff --git a/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs
new file mode 100644
index 0000000..4228559
--- /dev/null
+++ b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs
@@ -0,0 +1,198 @@
+using CatBox.NET;
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Litterbox;
+using CatBox.Tests.Helpers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Shouldly;
+
+namespace CatBox.Tests;
+
+///
+/// Integration tests for LitterboxClient that make real API calls
+/// Note: Litterbox uploads are temporary and auto-expire, so no cleanup is needed
+/// Run with: dotnet test --filter Category=Integration
+///
+[TestFixture]
+[Category("Integration")]
+public class LitterboxClientIntegrationTests
+{
+ private ILitterboxClient? _client;
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ if (!IntegrationTestConfig.IsConfigured)
+ {
+ Assert.Ignore("Integration tests skipped: CatBox:UserHash not configured. " +
+ "Set via: dotnet user-secrets set \"CatBox:UserHash\" \"your-hash\" " +
+ "or environment variable: CATBOX_USER_HASH=your-hash");
+ }
+
+ // Use DI container to properly configure the client with resilience handlers
+ var services = new ServiceCollection();
+ services.AddCatBoxServices(options =>
+ {
+ options.LitterboxUrl = IntegrationTestConfig.LitterboxUrl;
+ });
+
+ var serviceProvider = services.BuildServiceProvider();
+ _client = serviceProvider.GetRequiredService();
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithOneHourExpiry_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ File.Exists(testFilePath).ShouldBeTrue($"Test file not found: {testFilePath}");
+
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://litter.catbox.moe/");
+ TestContext.WriteLine($"Uploaded temporary file (1h expiry): {results[0]}");
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithTwelveHourExpiry_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ Expiry = ExpireAfter.TwelveHours
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://litter.catbox.moe/");
+ TestContext.WriteLine($"Uploaded temporary file (12h expiry): {results[0]}");
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithOneDayExpiry_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ Expiry = ExpireAfter.OneDay
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://litter.catbox.moe/");
+ TestContext.WriteLine($"Uploaded temporary file (1d expiry): {results[0]}");
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithThreeDaysExpiry_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath)],
+ Expiry = ExpireAfter.ThreeDays
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldNotBeNullOrWhiteSpace();
+ results[0].ShouldStartWith("https://litter.catbox.moe/");
+ TestContext.WriteLine($"Uploaded temporary file (3d expiry): {results[0]}");
+ }
+
+ [Test]
+ public async Task UploadImageAsync_WithMemoryStream_Succeeds()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var fileBytes = await File.ReadAllBytesAsync(testFilePath);
+ var stream = new MemoryStream(fileBytes);
+
+ var request = new TemporaryStreamUploadRequest
+ {
+ FileName = "test-temp-stream.png",
+ Stream = stream,
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var result = await _client!.UploadImageAsync(request);
+
+ // Assert
+ result.ShouldNotBeNullOrWhiteSpace();
+ result.ShouldStartWith("https://litter.catbox.moe/");
+ TestContext.WriteLine($"Uploaded temporary stream: {result}");
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithMultipleFiles_YieldsMultipleUrls()
+ {
+ // Arrange
+ var testFilePath = IntegrationTestConfig.GetTestFilePath();
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [new FileInfo(testFilePath), new FileInfo(testFilePath), new FileInfo(testFilePath)],
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in _client!.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(3);
+ results.ShouldAllBe(r => !string.IsNullOrWhiteSpace(r) && r!.StartsWith("https://litter.catbox.moe/"));
+ TestContext.WriteLine($"Uploaded {results.Count} temporary files");
+ foreach (var url in results)
+ {
+ TestContext.WriteLine($" - {url}");
+ }
+ }
+}
diff --git a/tests/CatBox.Tests/LitterboxClientTests.cs b/tests/CatBox.Tests/LitterboxClientTests.cs
new file mode 100644
index 0000000..4d3c06e
--- /dev/null
+++ b/tests/CatBox.Tests/LitterboxClientTests.cs
@@ -0,0 +1,399 @@
+using CatBox.NET;
+using CatBox.NET.Client;
+using CatBox.NET.Enums;
+using CatBox.NET.Requests.Litterbox;
+using CatBox.Tests.Helpers;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Shouldly;
+
+namespace CatBox.Tests;
+
+[TestFixture]
+public class LitterboxClientTests
+{
+ private const string TestLitterboxUrl = "https://litterbox.catbox.moe/resources/internals/api.php";
+ private const string TestTempFileUrl = "https://litter.catbox.moe/abc123.jpg";
+
+ private string _tempTestJpg = null!;
+ private string _tempTestPng = null!;
+ private string _tempTest1Jpg = null!;
+ private string _tempTest2Png = null!;
+ private string _tempTest3Gif = null!;
+ private string _tempMalwareExe = null!;
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ // Create temporary test files with minimal valid headers
+ _tempTestJpg = Path.Combine(Path.GetTempPath(), $"litterbox_test_{Guid.NewGuid()}.jpg");
+ _tempTestPng = Path.Combine(Path.GetTempPath(), $"litterbox_test_{Guid.NewGuid()}.png");
+ _tempTest1Jpg = Path.Combine(Path.GetTempPath(), $"litterbox_test1_{Guid.NewGuid()}.jpg");
+ _tempTest2Png = Path.Combine(Path.GetTempPath(), $"litterbox_test2_{Guid.NewGuid()}.png");
+ _tempTest3Gif = Path.Combine(Path.GetTempPath(), $"litterbox_test3_{Guid.NewGuid()}.gif");
+ _tempMalwareExe = Path.Combine(Path.GetTempPath(), $"litterbox_malware_{Guid.NewGuid()}.exe");
+
+ // JPEG header
+ File.WriteAllBytes(_tempTestJpg, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
+ File.WriteAllBytes(_tempTest1Jpg, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
+
+ // PNG header
+ File.WriteAllBytes(_tempTestPng, new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
+ File.WriteAllBytes(_tempTest2Png, new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
+
+ // GIF header
+ File.WriteAllBytes(_tempTest3Gif, new byte[] { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 });
+
+ // EXE header
+ File.WriteAllBytes(_tempMalwareExe, new byte[] { 0x4D, 0x5A, 0x90, 0x00 });
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ // Clean up temporary test files
+ TryDeleteFile(_tempTestJpg);
+ TryDeleteFile(_tempTestPng);
+ TryDeleteFile(_tempTest1Jpg);
+ TryDeleteFile(_tempTest2Png);
+ TryDeleteFile(_tempTest3Gif);
+ TryDeleteFile(_tempMalwareExe);
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ private IOptions CreateOptions()
+ {
+ var options = new CatboxOptions
+ {
+ LitterboxUrl = new Uri(TestLitterboxUrl)
+ };
+ return Options.Create(options);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithOneHourExpiry_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [testFile],
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithTwelveHourExpiry_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [testFile],
+ Expiry = ExpireAfter.TwelveHours
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithOneDayExpiry_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [testFile],
+ Expiry = ExpireAfter.OneDay
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithThreeDaysExpiry_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [testFile],
+ Expiry = ExpireAfter.ThreeDays
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithInvalidFileExtension_FiltersOutInvalidFiles()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var validFile = new FileInfo(_tempTestJpg);
+ var invalidFile = new FileInfo(_tempMalwareExe);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [validFile, invalidFile],
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ // Only the valid file should be uploaded
+ results.Count.ShouldBe(1);
+ }
+
+ [Test]
+ public void UploadMultipleImagesAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () =>
+ {
+ await foreach (var _ in client.UploadMultipleImagesAsync(null!))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_WithMultipleFiles_YieldsMultipleResponses()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var file1 = new FileInfo(_tempTest1Jpg);
+ var file2 = new FileInfo(_tempTest2Png);
+ var file3 = new FileInfo(_tempTest3Gif);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [file1, file2, file3],
+ Expiry = ExpireAfter.OneDay
+ };
+
+ // Act
+ var results = new List();
+ await foreach (var result in client.UploadMultipleImagesAsync(request))
+ {
+ results.Add(result);
+ }
+
+ // Assert
+ results.Count.ShouldBe(3);
+ results.ShouldAllBe(r => r == TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadMultipleImagesAsync_CancellationToken_CancelsOperation()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var testFile = new FileInfo(_tempTestJpg);
+ var request = new TemporaryFileUploadRequest
+ {
+ Files = [testFile],
+ Expiry = ExpireAfter.OneHour
+ };
+
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () =>
+ {
+ await foreach (var _ in client.UploadMultipleImagesAsync(request, cts.Token))
+ {
+ }
+ });
+ }
+
+ [Test]
+ public async Task UploadImageAsync_WithValidRequest_Succeeds()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var request = new TemporaryStreamUploadRequest
+ {
+ FileName = "test.jpg",
+ Stream = stream,
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act
+ var result = await client.UploadImageAsync(request);
+
+ // Assert
+ result.ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public void UploadImageAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ // Act & Assert
+ Should.Throw(async () => await client.UploadImageAsync(null!));
+ }
+
+ [Test]
+ public void UploadImageAsync_WithNullFileName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var request = new TemporaryStreamUploadRequest
+ {
+ FileName = null!,
+ Stream = stream,
+ Expiry = ExpireAfter.OneHour
+ };
+
+ // Act & Assert
+ Should.Throw(async () => await client.UploadImageAsync(request));
+ }
+
+ [Test]
+ public async Task UploadImageAsync_WithDifferentExpiryTimes_SucceedsWith()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var request = new TemporaryStreamUploadRequest
+ {
+ FileName = "test.jpg",
+ Stream = stream,
+ Expiry = ExpireAfter.ThreeDays
+ };
+
+ // Act
+ var result = await client.UploadImageAsync(request);
+
+ // Assert
+ result.ShouldBe(TestTempFileUrl);
+ }
+
+ [Test]
+ public async Task UploadImageAsync_CancellationToken_CancelsOperation()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var client = new LitterboxClient(httpClient, CreateOptions());
+
+ var stream = new MemoryStream([1, 2, 3, 4]);
+ var request = new TemporaryStreamUploadRequest
+ {
+ FileName = "test.jpg",
+ Stream = stream,
+ Expiry = ExpireAfter.OneHour
+ };
+
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () => await client.UploadImageAsync(request, cts.Token));
+ }
+
+ [Test]
+ public void Constructor_WithNullHttpClient_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() => new LitterboxClient(null!, CreateOptions()));
+ }
+
+ [Test]
+ public void Constructor_WithNullLitterboxUrl_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var httpClient = HttpClientTestHelper.CreateMockHttpClient(TestTempFileUrl);
+ var options = Options.Create(new CatboxOptions { LitterboxUrl = null });
+
+ // Act & Assert
+ Should.Throw(() => new LitterboxClient(httpClient, options));
+ }
+}
diff --git a/tests/CatBox.Tests/README.md b/tests/CatBox.Tests/README.md
new file mode 100644
index 0000000..cfbc9bf
--- /dev/null
+++ b/tests/CatBox.Tests/README.md
@@ -0,0 +1,302 @@
+# CatBox.NET Test Suite
+
+This directory contains the test suite for the CatBox.NET library, including both unit tests and integration tests for the CatBox.moe and Litterbox file hosting services.
+
+## Test Categories
+
+### Unit Tests (No API Calls Required)
+
+These tests use mocked HTTP clients and don't require any configuration or API credentials:
+
+- **CommonTests.cs** - Tests for validation logic, file extension checking, and request validation
+- **CatBoxClientTests.cs** - Tests for CatBox client functionality with mocked HTTP responses
+- **LitterboxClientTests.cs** - Tests for Litterbox client functionality with mocked HTTP responses
+
+### Integration Tests (Real API Calls)
+
+These tests make actual API calls to CatBox.moe and Litterbox services and require a valid CatBox user hash:
+
+- **CatBoxClientIntegrationTests.cs** - Real API testing for:
+ - File uploads (from disk, stream, and URL)
+ - Album creation and management
+ - File deletion
+ - Automatic cleanup of test resources
+
+- **LitterboxClientIntegrationTests.cs** - Real API testing for:
+ - Temporary file uploads with various expiry times (1h, 12h, 1d, 3d)
+ - Stream-based uploads
+ - Multiple file uploads
+
+## Prerequisites
+
+- **.NET 9.0 SDK** or later
+- **CatBox.moe account** (only for integration tests)
+
+## Getting Your CatBox User Hash
+
+Integration tests require a CatBox user hash to enable file and album deletion. Follow these steps:
+
+1. **Create a CatBox Account**
+ - Visit https://catbox.moe and create an account (free)
+
+2. **Access Your User Management Page**
+ - Navigate to https://catbox.moe/user/manage.php
+ - Log in if not already logged in
+
+3. **Locate Your User Hash**
+ - On the management page, find the "User Hash" field
+ - Copy the alphanumeric hash value (e.g., `1234567890abcdef1234567890abcdef`)
+
+## Configuration for Integration Tests
+
+Integration tests will automatically skip with an informative message if no credentials are configured. Configure using one of the following methods:
+
+### Option A: User Secrets (Recommended for Local Development)
+
+User Secrets store credentials outside your project directory, preventing accidental commits to source control.
+
+```bash
+# Navigate to the test project directory
+cd tests/CatBox.Tests
+
+# Set your user hash
+dotnet user-secrets set "CatBox:UserHash" "your-user-hash-here"
+
+# Verify it was set (optional)
+dotnet user-secrets list
+```
+
+**Where are secrets stored?**
+- Windows: `%APPDATA%\Microsoft\UserSecrets\f7c8b9e3-4a5d-4e2f-9b3c-1d8e7f6a5b4c\secrets.json`
+- Linux/Mac: `~/.microsoft/usersecrets/f7c8b9e3-4a5d-4e2f-9b3c-1d8e7f6a5b4c/secrets.json`
+
+### Option B: Environment Variables (CI/CD & Alternative)
+
+Environment variables are useful for CI/CD pipelines and as an alternative configuration method.
+
+**Windows PowerShell:**
+```powershell
+$env:CATBOX_USER_HASH="your-user-hash-here"
+```
+
+**Windows Command Prompt:**
+```cmd
+set CATBOX_USER_HASH=your-user-hash-here
+```
+
+**Linux / macOS:**
+```bash
+export CATBOX_USER_HASH=your-user-hash-here
+```
+
+**Permanent Configuration (Linux/macOS):**
+```bash
+# Add to ~/.bashrc or ~/.zshrc
+echo 'export CATBOX_USER_HASH="your-user-hash-here"' >> ~/.bashrc
+source ~/.bashrc
+```
+
+### Configuration Priority
+
+When both are present, User Secrets take precedence over Environment Variables:
+1. User Secrets (highest priority)
+2. Environment Variables (fallback)
+
+## Running Tests
+
+### Run All Tests
+
+```bash
+dotnet test
+```
+
+### Run Only Unit Tests
+
+Excludes integration tests, perfect for quick local development:
+
+```bash
+dotnet test --filter Category!=Integration
+```
+
+### Run Only Integration Tests
+
+Runs only tests that make real API calls:
+
+```bash
+dotnet test --filter Category=Integration
+```
+
+### Run Specific Test Class
+
+```bash
+# Run only CatBox client tests
+dotnet test --filter FullyQualifiedName~CatBoxClientTests
+
+# Run only integration tests for CatBox
+dotnet test --filter FullyQualifiedName~CatBoxClientIntegrationTests
+
+# Run only Litterbox tests
+dotnet test --filter FullyQualifiedName~LitterboxClientTests
+```
+
+### Run with Verbose Output
+
+```bash
+dotnet test --logger "console;verbosity=detailed"
+```
+
+## Test Behavior
+
+### Unit Tests
+- Always run regardless of configuration
+- Complete instantly (no network I/O)
+- Use mocked HTTP responses
+- Test validation logic and code paths
+
+### Integration Tests Without Configuration
+If `CatBox:UserHash` is not configured, integration tests will:
+- Skip gracefully with a message
+- Display setup instructions in the output
+- Not fail or error
+- Not make any API calls
+
+Example skip message:
+```
+Integration tests skipped: CatBox:UserHash not configured.
+Set via: dotnet user-secrets set "CatBox:UserHash" "your-hash"
+or environment variable: CATBOX_USER_HASH=your-hash
+```
+
+### Integration Tests With Configuration
+- Make real HTTP requests to CatBox.moe/Litterbox
+- Upload actual test files (PNG image)
+- Create, modify, and delete albums
+- **Automatically clean up** all uploaded resources in teardown
+- May take several seconds to complete
+
+## Project Structure
+
+```
+CatBox.Tests/
+├── README.md # This file
+├── CatBox.Tests.csproj # Project file with UserSecretsId
+├── CommonTests.cs # Unit tests for validation logic
+├── CatBoxClientTests.cs # Unit tests for CatBox client
+├── LitterboxClientTests.cs # Unit tests for Litterbox client
+├── CatBoxClientIntegrationTests.cs # Integration tests for CatBox
+├── LitterboxClientIntegrationTests.cs # Integration tests for Litterbox
+├── Helpers/
+│ ├── HttpClientTestHelper.cs # Mock HTTP client helper
+│ └── IntegrationTestConfig.cs # Configuration for integration tests
+└── Images/
+ └── test-file.png # PNG test file for uploads
+```
+
+## Integration Test Cleanup
+
+Integration tests automatically clean up all resources they create:
+
+1. **File Tracking**: Every uploaded file URL is tracked in a static collection
+2. **Album Tracking**: Every created album ID is tracked separately
+3. **Cleanup Order**:
+ - Albums are deleted first (they reference files)
+ - Individual files are deleted second
+4. **Teardown Execution**: Cleanup runs even if tests fail via `[OneTimeTearDown]`
+
+## Contributing
+
+### Adding New Unit Tests
+
+1. Create test methods in the appropriate test class
+2. Use `[Test]` or `[TestCase]` attributes
+3. Follow Arrange-Act-Assert pattern
+4. Use Shouldly for assertions
+
+Example:
+```csharp
+[Test]
+public void MethodName_Scenario_ExpectedBehavior()
+{
+ // Arrange
+ var input = "test";
+
+ // Act
+ var result = MethodUnderTest(input);
+
+ // Assert
+ result.ShouldBe("expected");
+}
+```
+
+### Adding New Integration Tests
+
+1. Add tests to `*IntegrationTests.cs` classes
+2. Mark with `[Category("Integration")]` attribute
+3. Use `[Order(n)]` to control execution sequence if needed
+4. Track uploaded resources for cleanup:
+ ```csharp
+ TrackUploadedFile(uploadedUrl);
+ TrackCreatedAlbum(albumUrl);
+ ```
+
+## Troubleshooting
+
+### Integration Tests Are Skipping
+
+**Symptom**: Integration tests show as "Skipped" in test output
+
+**Solution**: Configure your CatBox user hash using one of the methods in the Configuration section above
+
+### How to Verify Configuration
+
+```bash
+# Check if user secrets are configured
+cd tests/CatBox.Tests
+dotnet user-secrets list
+
+# Check environment variable (Windows PowerShell)
+$env:CATBOX_USER_HASH
+
+# Check environment variable (Linux/Mac)
+echo $CATBOX_USER_HASH
+```
+
+### Integration Tests Are Failing
+
+1. **Verify your user hash is correct**: Visit https://catbox.moe/user/manage.php and confirm the hash
+2. **Check network connectivity**: Ensure you can access catbox.moe from your network
+3. **Review test output**: Look for specific error messages about API responses
+4. **Check API limits**: CatBox may have rate limits or temporary restrictions
+
+### User Secrets Not Working
+
+If user secrets aren't being recognized:
+
+1. Verify you're in the correct directory: `tests/CatBox.Tests`
+2. Check that `UserSecretsId` exists in `CatBox.Tests.csproj`
+3. Verify the secrets file exists at the location shown above
+4. Try setting an environment variable as a fallback
+
+### Build Errors After Adding Packages
+
+If you encounter package version conflicts:
+
+```bash
+# Clean and restore
+dotnet clean
+dotnet restore
+dotnet build
+```
+
+## Testing Framework Reference
+
+- **NUnit**: Test framework - https://nunit.org/
+- **Shouldly**: Assertion library - https://docs.shouldly.org/
+- **NSubstitute**: Mocking library - https://nsubstitute.github.io/
+
+## Additional Resources
+
+- [CatBox.NET Main Documentation](../../README.md)
+- [CatBox API Documentation](https://catbox.moe/api)
+- [.NET User Secrets Documentation](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets)
+- [NUnit Documentation](https://docs.nunit.org/)
diff --git a/tests/CatBox.Tests/Usings.cs b/tests/CatBox.Tests/Usings.cs
deleted file mode 100644
index cefced4..0000000
--- a/tests/CatBox.Tests/Usings.cs
+++ /dev/null
@@ -1 +0,0 @@
-global using NUnit.Framework;
\ No newline at end of file