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