diff --git a/.idea/.idea.CatBox.NET/.idea/vcs.xml b/.idea/.idea.CatBox.NET/.idea/vcs.xml index 35eb1dd..e75ad0f 100644 --- a/.idea/.idea.CatBox.NET/.idea/vcs.xml +++ b/.idea/.idea.CatBox.NET/.idea/vcs.xml @@ -1,5 +1,13 @@ + + + diff --git a/src/CatBox.NET/CatBox.NET.csproj b/src/CatBox.NET/CatBox.NET.csproj index e510aed..b9dd03c 100644 --- a/src/CatBox.NET/CatBox.NET.csproj +++ b/src/CatBox.NET/CatBox.NET.csproj @@ -4,7 +4,7 @@ enable enable latest - 1.0 + 1.1 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 @@ -15,6 +15,7 @@ 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 + true diff --git a/src/CatBox.NET/CatBox.NET.csproj.DotSettings b/src/CatBox.NET/CatBox.NET.csproj.DotSettings new file mode 100644 index 0000000..89316e4 --- /dev/null +++ b/src/CatBox.NET/CatBox.NET.csproj.DotSettings @@ -0,0 +1,2 @@ + + Library \ No newline at end of file diff --git a/src/CatBox.NET/CatBoxServices.cs b/src/CatBox.NET/CatBoxServices.cs index b532b1f..6e0840a 100644 --- a/src/CatBox.NET/CatBoxServices.cs +++ b/src/CatBox.NET/CatBoxServices.cs @@ -1,4 +1,6 @@ -using CatBox.NET.Client; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using CatBox.NET.Client; using CatBox.NET.Exceptions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -30,7 +32,7 @@ public IServiceCollection AddCatBoxServices(Action setupAction) return services; } - private IServiceCollection AddHttpClientWithMessageHandler() + private IServiceCollection AddHttpClientWithMessageHandler() where TInterface : class where TImplementation : class, TInterface { @@ -48,8 +50,27 @@ private IServiceCollection AddHttpClientWithMessageHandler + { + // Don't retry custom CatBox exceptions - these are client errors (4xx) + // Our exceptions inherit from Exception, not HttpRequestException + if (args.Outcome.Exception is not null and not HttpRequestException) + return ValueTask.FromResult(false); + + // Retry HttpRequestException (network failures, timeouts) + if (args.Outcome.Exception is HttpRequestException) + return ValueTask.FromResult(true); + + // Retry 5xx server errors + if (args.Outcome.Result is { } response) + return ValueTask.FromResult(response.StatusCode >= HttpStatusCode.InternalServerError); + + return ValueTask.FromResult(false); + } }; + + }); return services; diff --git a/src/CatBox.NET/CatboxOptions.cs b/src/CatBox.NET/CatboxOptions.cs index 45a6aea..de37222 100644 --- a/src/CatBox.NET/CatboxOptions.cs +++ b/src/CatBox.NET/CatboxOptions.cs @@ -9,7 +9,12 @@ public sealed record CatboxOptions /// URL for the catbox.moe domain /// public Uri? CatBoxUrl { get; set; } - + + /// + /// Base URL for downloading CatBox files (default: https://files.catbox.moe/) + /// + public Uri CatBoxFilesUrl { get; set; } = new("https://files.catbox.moe/"); + /// /// URL for the litterbox.moe domain /// diff --git a/src/CatBox.NET/Client/CatBox/CatBox.cs b/src/CatBox.NET/Client/CatBox/CatBox.cs index c6746d0..6fb3f00 100644 --- a/src/CatBox.NET/Client/CatBox/CatBox.cs +++ b/src/CatBox.NET/Client/CatBox/CatBox.cs @@ -1,6 +1,11 @@ -using CatBox.NET.Requests.Album; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +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.NET.Responses.Album; namespace CatBox.NET.Client; @@ -24,56 +29,187 @@ public interface ICatBox /// Cancellation Token. /// Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default); -} -/// -public sealed class Catbox : ICatBox -{ - private readonly ICatBoxClient _client; + /// + /// Uploads files to an existing album, respecting the 500-file limit. + /// Fetches album first to determine available capacity and uploads only what fits. + /// + /// Album upload request + /// Cancellation Token + /// Result containing upload counts and remaining capacity + Task UploadImagesToAlbumSafeAsync(UploadToAlbumRequest request, CancellationToken ct = default); /// - /// Instantiate a new catbox class + /// Gets album information including the list of files /// - /// The CatBox Api Client () - public Catbox(ICatBoxClient client) - { - _client = client; - } - + /// The album ID to retrieve + /// Cancellation Token + /// Album information with parsed file list + Task GetAlbumAsync(string albumId, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified directory + /// + /// The file name (e.g., "abc123.png") + /// Directory to save the file + /// Cancellation Token + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified path + /// + /// The file name (e.g., "abc123.png") + /// Directory path to save the file + /// Cancellation Token + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default); + + /// + /// Downloads all files from an album to the specified directory + /// + /// The album ID to download + /// Directory to save the files + /// Cancellation Token + /// Yields FileInfo for each downloaded file + IAsyncEnumerable DownloadAlbumAsync(string albumId, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads all files from an album to the specified path + /// + /// The album ID to download + /// Directory path to save the files + /// Cancellation Token + /// Yields FileInfo for each downloaded file + IAsyncEnumerable DownloadAlbumAsync(string albumId, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default); +} + +/// +/// +/// Instantiate a new catbox class +/// +/// The CatBox Api Client () +public sealed class Catbox(ICatBoxClient client) : ICatBox +{ /// - public Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default) + public async Task CreateAlbumFromFilesAsync(CreateAlbumRequest requestFromFiles, CancellationToken ct = default) { - var enumerable = Upload(requestFromFiles, ct); + var uploadedFiles = await Upload(requestFromFiles, ct).ToListAsync(ct).ConfigureAwait(false); var createAlbumRequest = new RemoteCreateAlbumRequest { Title = requestFromFiles.Title, Description = requestFromFiles.Description, UserHash = requestFromFiles.UserHash, - Files = enumerable.ToBlockingEnumerable(cancellationToken: ct) + Files = uploadedFiles }; - return _client.CreateAlbumAsync(createAlbumRequest, ct); + return await client.CreateAlbumAsync(createAlbumRequest, ct).ConfigureAwait(false); } /// - public Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default) + public async Task UploadImagesToAlbumAsync(UploadToAlbumRequest request, CancellationToken ct = default) { - var requestType = request.Request; - var userHash = request.UserHash; - var albumId = request.AlbumId; + var uploadedFiles = await Upload(request, ct).ToListAsync(ct).ConfigureAwait(false); - var enumerable = Upload(request, ct); + return await client.ModifyAlbumAsync(new ModifyAlbumImagesRequest + { + Request = request.Request, + UserHash = request.UserHash, + AlbumId = request.AlbumId, + Files = uploadedFiles + }, ct).ConfigureAwait(false); + } - return _client.ModifyAlbumAsync(new ModifyAlbumImagesRequest + /// + public async Task UploadImagesToAlbumSafeAsync(UploadToAlbumRequest request, CancellationToken ct = default) + { + // Get album to determine current capacity + var album = await GetAlbumAsync(request.AlbumId, ct).ConfigureAwait(false); + var currentCount = album.Files.Length; + var remainingCapacity = Common.MaxAlbumFiles - currentCount; + + // Count total files in request (materializes lazy enumerables) + var (totalFiles, materializedRequest) = MaterializeAndCountRequest(request); + + // If album is full, return early + if (remainingCapacity <= 0) { - Request = requestType, - UserHash = userHash, - AlbumId = albumId, - Files = enumerable.ToBlockingEnumerable() - }, ct); + return new AlbumUploadResult + { + AlbumUrl = null, + FilesUploaded = 0, + FilesSkipped = totalFiles, + RemainingCapacity = 0 + }; + } + + // Calculate how many to upload + var filesToUpload = Math.Min(remainingCapacity, totalFiles); + var filesToSkip = totalFiles - filesToUpload; + + // If we need to upload all files, use the original request + // Otherwise, create a sliced request + var requestToUse = filesToUpload == totalFiles + ? materializedRequest + : TakeFilesFromRequest(materializedRequest, filesToUpload); + + // Upload and add to album + var result = await UploadImagesToAlbumAsync(requestToUse, ct).ConfigureAwait(false); + + return new AlbumUploadResult + { + AlbumUrl = result, + FilesUploaded = filesToUpload, + FilesSkipped = filesToSkip, + RemainingCapacity = remainingCapacity - filesToUpload + }; } - + + /// + public async Task GetAlbumAsync(string albumId, CancellationToken ct = default) + { + return await client.GetAlbumAsync(new GetAlbumRequest { AlbumId = albumId }, ct).ConfigureAwait(false); + } + + /// + public async Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default) + { + await client.DownloadFileAsync(fileName, destination, ct).ConfigureAwait(false); + } + + /// + public async Task DownloadFileAsync(string fileName, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default) + { + await client.DownloadFileAsync(fileName, destinationPath, ct).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable DownloadAlbumAsync(string albumId, DirectoryInfo destination, [EnumeratorCancellation] CancellationToken ct = default) + { + var album = await GetAlbumAsync(albumId, ct).ConfigureAwait(false); + + if (!destination.Exists) + destination.Create(); + + foreach (var fileName in album.Files) + { + ct.ThrowIfCancellationRequested(); + + var filePath = Path.Combine(destination.FullName, fileName); + + await client.DownloadFileAsync(fileName, destination, ct).ConfigureAwait(false); + + yield return new FileInfo(filePath); + } + } + + /// + public IAsyncEnumerable DownloadAlbumAsync(string albumId, [StringSyntax(StringSyntaxAttribute.Uri)] string destinationPath, CancellationToken ct = default) + { + return DownloadAlbumAsync(albumId, new DirectoryInfo(destinationPath), ct); + } + /// /// Upload files based on the requestBase type /// @@ -85,10 +221,74 @@ public Catbox(ICatBoxClient client) { 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), + { 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") }; } + + /// + /// Materializes lazy enumerables and counts total files in the request + /// + private static (int Count, UploadToAlbumRequest MaterializedRequest) MaterializeAndCountRequest(UploadToAlbumRequest request) + { + return request.UploadRequest switch + { + { IsFirst: true } => MaterializeFileUploadRequest(request), + { IsSecond: true } => MaterializeStreamUploadRequest(request), + { IsThird: true } => MaterializeUrlUploadRequest(request), + _ => throw new InvalidOperationException("Invalid request type") + }; + + static (int, UploadToAlbumRequest) MaterializeFileUploadRequest(UploadToAlbumRequest req) + { + var files = req.UploadRequest.First.Files.ToList(); + var materialized = req with + { + UploadRequest = req.UploadRequest.First with { Files = files } + }; + return (files.Count, materialized); + } + + static (int, UploadToAlbumRequest) MaterializeStreamUploadRequest(UploadToAlbumRequest req) + { + var streams = req.UploadRequest.Second.ToList(); + var materialized = req with { UploadRequest = streams }; + return (streams.Count, materialized); + } + + static (int, UploadToAlbumRequest) MaterializeUrlUploadRequest(UploadToAlbumRequest req) + { + var urls = req.UploadRequest.Third.Files.ToList(); + var materialized = req with + { + UploadRequest = req.UploadRequest.Third with { Files = urls } + }; + return (urls.Count, materialized); + } + } + + /// + /// Creates a new request with only the first N files + /// + private static UploadToAlbumRequest TakeFilesFromRequest(UploadToAlbumRequest request, int count) + { + return request.UploadRequest switch + { + { IsFirst: true } => request with + { + UploadRequest = request.UploadRequest.First with { Files = request.UploadRequest.First.Files.Take(count) } + }, + { IsSecond: true } => request with + { + UploadRequest = request.UploadRequest.Second.Take(count).ToList() + }, + { IsThird: true } => request with + { + UploadRequest = request.UploadRequest.Third with { Files = request.UploadRequest.Third.Files.Take(count) } + }, + _ => throw new InvalidOperationException("Invalid request 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 index 888adf3..042b3bf 100644 --- a/src/CatBox.NET/Client/CatBox/CatBoxClient.cs +++ b/src/CatBox.NET/Client/CatBox/CatBoxClient.cs @@ -1,10 +1,13 @@ using System.Runtime.CompilerServices; +using System.Text.Json; 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.NET.Responses; +using CatBox.NET.Responses.Album; using Microsoft.Extensions.Options; using static CatBox.NET.Client.Common; @@ -105,6 +108,41 @@ public interface ICatBoxClient /// .

/// Use to edit an album Task ModifyAlbumAsync(ModifyAlbumImagesRequest modifyAlbumImagesRequest, CancellationToken ct = default); + + /// + /// Gets album information including the list of files + /// + /// Request containing the album ID + /// Cancellation Token + /// When is null + /// When is null or whitespace + /// When something bad happens when talking to the API + /// Album information with parsed file list + Task GetAlbumAsync(GetAlbumRequest getAlbumRequest, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified directory + /// + /// The file name (e.g., "abc123.png") + /// Directory to save the file + /// Cancellation Token + /// When fileName is null or whitespace + /// When destination is null + /// When something bad happens when talking to the API + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default); + + /// + /// Downloads a file from CatBox to the specified path + /// + /// The file name (e.g., "abc123.png") + /// Directory path to save the file + /// Cancellation Token + /// When fileName is null or whitespace + /// When destinationPath is null or whitespace + /// When something bad happens when talking to the API + /// Skips download if file already exists at destination + Task DownloadFileAsync(string fileName, string destinationPath, CancellationToken ct = default); } public sealed class CatBoxClient : ICatBoxClient @@ -152,8 +190,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) 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); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -178,8 +216,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) 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); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -200,8 +238,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) 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); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -221,24 +259,18 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) { new StringContent(fileNames), RequestParameters.Files } }; - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// public async Task CreateAlbumAsync(RemoteCreateAlbumRequest remoteCreateAlbumRequest, CancellationToken ct = default) { - ThrowIfAlbumCreationRequestIsInvalid(remoteCreateAlbumRequest); + Throw.IfAlbumCreationRequestIsInvalid(remoteCreateAlbumRequest); - var links = remoteCreateAlbumRequest.Files.Select(link => - { - if (link?.Contains(_catboxOptions.CatBoxUrl!.Host) is true) - { - return new Uri(link).PathAndQuery[1..]; - } + var links = remoteCreateAlbumRequest.Files.Select(link => link.ToCatboxImageName()).ToList(); - return link; - }); + Throw.IfAlbumFileLimitExceeds(links.Count); var fileNames = string.Join(" ", links); ArgumentException.ThrowIfNullOrWhiteSpace(fileNames); @@ -256,8 +288,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) 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); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// @@ -284,8 +316,8 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) { new StringContent(fileNames), RequestParameters.Files } }; - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } /// @@ -297,7 +329,12 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) Throw.IfAlbumRequestTypeInvalid(IsAlbumRequestTypeValid(modifyAlbumImagesRequest), nameof(modifyAlbumImagesRequest.Request)); Throw.IfAlbumOperationInvalid(modifyAlbumImagesRequest.Request, RequestType.AddToAlbum, RequestType.RemoveFromAlbum, RequestType.DeleteAlbum); - var fileNames = string.Join(" ", modifyAlbumImagesRequest.Files); + var files = modifyAlbumImagesRequest.Files.ToList(); + + if (modifyAlbumImagesRequest.Request == RequestType.AddToAlbum) + Throw.IfAlbumFileLimitExceeds(files.Count); + + var fileNames = string.Join(" ", files); using var content = new MultipartFormDataContent { @@ -313,7 +350,73 @@ public CatBoxClient(HttpClient client, IOptions catboxOptions) content.Add(new StringContent(fileNames), RequestParameters.Files); } - using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct); - return await response.Content.ReadAsStringAsync(ct); + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + /// + public async Task GetAlbumAsync(GetAlbumRequest getAlbumRequest, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(getAlbumRequest); + ArgumentException.ThrowIfNullOrWhiteSpace(getAlbumRequest.AlbumId); + + using var content = new MultipartFormDataContent + { + { new StringContent(RequestType.GetAlbum), RequestParameters.Request }, + { new StringContent(getAlbumRequest.AlbumId), RequestParameters.AlbumIdShort } + }; + + using var response = await _client.PostAsync(_catboxOptions.CatBoxUrl, content, ct).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + var apiResponse = JsonSerializer.Deserialize(json, CatBoxJsonContext.Default.GetAlbumApiResponse); + + // ExceptionHandler will throw CatBoxAlbumNotFoundException for HTTP 400 errors + // This check is only for unexpected response format + if (apiResponse?.Data is null) + throw new HttpRequestException($"Unexpected response format: {json}"); + + var files = string.IsNullOrWhiteSpace(apiResponse.Data.Files) + ? [] + : apiResponse.Data.Files.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + return new AlbumInfo + { + Title = apiResponse.Data.Title, + Description = apiResponse.Data.Description, + AlbumId = apiResponse.Data.Short, + DateCreated = apiResponse.Data.DateCreated, + Files = files + }; + } + + /// + public async Task DownloadFileAsync(string fileName, DirectoryInfo destination, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentNullException.ThrowIfNull(destination); + + if (!destination.Exists) + destination.Create(); + + var filePath = Path.Combine(destination.FullName, fileName); + + // Skip if file exists + if (File.Exists(filePath)) + return; + + var fileUrl = new Uri(_catboxOptions.CatBoxFilesUrl, fileName); + + using var response = await _client.GetAsync(fileUrl, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(filePath); + await response.Content.CopyToAsync(fileStream, ct).ConfigureAwait(false); + } + + /// + public Task DownloadFileAsync(string fileName, string destinationPath, CancellationToken ct = default) + { + return DownloadFileAsync(fileName, new DirectoryInfo(destinationPath), ct); } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/Common.cs b/src/CatBox.NET/Client/Common.cs index 889be27..bc53b5d 100644 --- a/src/CatBox.NET/Client/Common.cs +++ b/src/CatBox.NET/Client/Common.cs @@ -1,12 +1,16 @@ -using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using CatBox.NET.Enums; -using CatBox.NET.Requests.Album.Create; using CatBox.NET.Requests.Album.Modify; namespace CatBox.NET.Client; internal static class Common { + /// + /// Maximum number of files allowed in a CatBox album + /// + public const int MaxAlbumFiles = 500; + /// /// These file extensions are not allowed by the API, so filter them out /// @@ -23,20 +27,6 @@ _ when extension.Contains(".doc") => false, }; } - /// - /// 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); - } - /// /// 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 @@ -56,4 +46,59 @@ public static bool IsAlbumRequestTypeValid(ModifyAlbumImagesRequest imagesReques request == RequestType.RemoveFromAlbum || request == RequestType.DeleteAlbum) && hasUserHash; } + + /// The URL or file name + extension(string? url) + { + /// + /// Extracts the file name from a CatBox file URL (files.catbox.moe) or returns the original string. + /// + /// "https://files.catbox.moe/abc123.jpg" → "abc123.jpg" + /// The extracted file name or original string + public string? ToCatboxImageName() + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.Contains("files.catbox.moe")) + return url.Split('/')[^1]; + + return url; + } + + /// + /// Extracts the album short code from a CatBox album URL (catbox.moe/c/) or returns the original string. + /// + /// "https://catbox.moe/c/abc123" → "abc123" + /// The extracted album short code or original string + public string? ToAlbumShortCode() + { + if (string.IsNullOrWhiteSpace(url)) + return url; + + if (url.Contains("catbox.moe/c/")) + return url.Split('/')[^1]; + + return url; + } + } + + /// The async enumerable source + extension(IAsyncEnumerable source) + { + /// + /// Asynchronously collects all elements from an IAsyncEnumerable into a List + /// + /// Cancellation token + /// A list containing all elements + public async Task> ToListAsync(CancellationToken ct = default) + { + var list = new List(); + await foreach (var item in source.WithCancellation(ct).ConfigureAwait(false)) + { + list.Add(item); + } + return list; + } + } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs index 6a8dc4c..2aafaf3 100644 --- a/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs +++ b/src/CatBox.NET/Client/Litterbox/LitterboxClient.cs @@ -71,9 +71,9 @@ public LitterboxClient(HttpClient client, IOptions catboxOptions) { new StringContent(RequestType.UploadFile), RequestParameters.Request }, { new StringContent(temporaryFileUploadRequest.Expiry), RequestParameters.Expiry }, { new StreamContent(fileStream), RequestParameters.FileToUpload, imageFile.Name } - }, ct); + }, ct).ConfigureAwait(false); - yield return await response.Content.ReadAsStringAsync(ct); + yield return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } @@ -94,8 +94,8 @@ public LitterboxClient(HttpClient client, IOptions catboxOptions) new StreamContent(temporaryStreamUploadRequest.Stream), RequestParameters.FileToUpload, temporaryStreamUploadRequest.FileName } - }, ct); + }, ct).ConfigureAwait(false); - return await response.Content.ReadAsStringAsync(ct); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/CatBox.NET/Client/Throw.cs b/src/CatBox.NET/Client/Throw.cs index c103d16..619abfb 100644 --- a/src/CatBox.NET/Client/Throw.cs +++ b/src/CatBox.NET/Client/Throw.cs @@ -1,5 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using CatBox.NET.Enums; -using CatBox.NET.Requests.Album.Modify; +using CatBox.NET.Requests.Album.Create; namespace CatBox.NET.Client; @@ -38,7 +39,7 @@ public static void IfLitterboxFileSizeExceeds(long fileSize, long maxSize) /// 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) + public static void IfAlbumRequestTypeInvalid([DoesNotReturnIf(false)] bool isValid, string paramName) { if (!isValid) throw new ArgumentException("Invalid Request Type for album endpoint", paramName); @@ -59,4 +60,30 @@ public static void IfAlbumOperationInvalid(RequestType request, params RequestTy throw new InvalidOperationException("Invalid Request Type for album endpoint"); } + + /// + /// 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 IfAlbumCreationRequestIsInvalid(AlbumCreationRequestBase requestBase) + { + ArgumentNullException.ThrowIfNull(requestBase); + ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Description); + ArgumentException.ThrowIfNullOrWhiteSpace(requestBase.Title); + } + + /// + /// Throws if the file count exceeds the album limit + /// + /// The number of files being added to the album + /// The maximum allowed files (default: ) + /// When file count exceeds the maximum + public static void IfAlbumFileLimitExceeds(int fileCount, int maxFiles = Common.MaxAlbumFiles) + { + if (fileCount > maxFiles) + throw new Exceptions.CatBoxAlbumFileLimitExceededException(fileCount); + } } diff --git a/src/CatBox.NET/Enums/RequestType.cs b/src/CatBox.NET/Enums/RequestType.cs index 84f554a..82a0f37 100644 --- a/src/CatBox.NET/Enums/RequestType.cs +++ b/src/CatBox.NET/Enums/RequestType.cs @@ -14,4 +14,5 @@ namespace CatBox.NET.Enums; [Member("AddToAlbum", "addtoalbum")] [Member("RemoveFromAlbum", "removefromalbum")] [Member("DeleteAlbum", "deletealbum")] +[Member("GetAlbum", "getalbum")] public sealed partial class RequestType; diff --git a/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs index 71f2a30..0716298 100644 --- a/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs +++ b/src/CatBox.NET/Exceptions/CatBoxAPIExceptions.cs @@ -1,7 +1,9 @@ using System.Diagnostics; using System.Net; +using System.Text.Json; using CatBox.NET.Client; using CatBox.NET.Logging; +using CatBox.NET.Responses; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -29,6 +31,24 @@ 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 userhash provided! +internal sealed class CatBoxMissingUserHashException : Exception +{ + public override string Message { get; } = "The UserHash parameter was not provided. UserHash is required for album modification and deletion operations."; +} + +// API Response Message: No valid link given. +internal sealed class CatBoxMissingUrlException : Exception +{ + public override string Message { get; } = "The URL parameter was not provided or is invalid. A valid URL is required for URL upload operations."; +} + +// API Response Message: Tried to delete a file that didn't belong to that userhash. +internal sealed class CatBoxFileOwnershipException : Exception +{ + public override string Message { get; } = "Attempted to delete a file that does not belong to the provided userhash. You can only delete files you own."; +} + //API Response Message: No expire time specified. internal sealed class LitterboxInvalidExpiry : Exception { @@ -47,36 +67,97 @@ internal sealed class CatBoxFileSizeLimitExceededException(long fileSize) : Exce 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)"; } +// Album exceeds CatBox's file limit +internal sealed class CatBoxAlbumFileLimitExceededException(int fileCount) : Exception +{ + public override string Message { get; } = $"Album exceeds CatBox's {Common.MaxAlbumFiles} file limit. Attempted to add {fileCount} files."; +} + internal sealed class ExceptionHandler(ILogger? logger = null) : DelegatingHandler { + // Plain-text error messages (HTTP 412) 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 MissingRequestType = "No request type given?"; private const string MissingFileParameter = "No files given."; + private const string MissingUserHash = "No userhash provided!"; + private const string MissingUrl = "No valid link given."; + private const string FileOwnershipMismatch = "Tried to delete a file that didn't belong to that userhash."; private const string InvalidExpiry = "No expire time specified."; - + + // JSON error messages (HTTP 400) + private const string JsonAlbumNotFound = "An album was not found. Either the album never existed, or was deleted."; + 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) + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Only process error status codes + if (response.IsSuccessStatusCode) return response; - - var content = response.Content; - var apiErrorMessage = await content.ReadAsStringAsync(cancellationToken); - _logger.LogCatBoxAPIException(response.StatusCode, apiErrorMessage); - - throw apiErrorMessage switch + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + // Try JSON parsing first (HTTP 400 pattern) + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var exception = TryParseJsonError(content); + if (exception is not null) + { + _logger.LogCatBoxAPIException(response.StatusCode, content); + throw exception; + } + } + + // Fall back to plain-text matching (HTTP 412 pattern) + if (response.StatusCode == HttpStatusCode.PreconditionFailed) + { + _logger.LogCatBoxAPIException(response.StatusCode, content); + throw MatchPlainTextError(content, response.StatusCode); + } + + // Return response for unhandled status codes (let caller handle) + return response; + } + + private static Exception? TryParseJsonError(string content) + { + try + { + var errorResponse = JsonSerializer.Deserialize(content, CatBoxJsonContext.Default.CatBoxApiErrorResponse); + if (errorResponse is { Success: false, Data.Error: not null }) + { + return errorResponse.Data.Error switch + { + var e when e.Equals(JsonAlbumNotFound, StringComparison.OrdinalIgnoreCase) => new CatBoxAlbumNotFoundException(), + _ => new HttpRequestException($"CatBox API Error: {errorResponse.Data.Error}") + }; + } + } + catch (JsonException) + { + // Not valid JSON, return null to try other parsing + } + return null; + } + + private static Exception MatchPlainTextError(string content, HttpStatusCode statusCode) + { + return content switch { AlbumNotFound => new CatBoxAlbumNotFoundException(), FileNotFound => new CatBoxFileNotFoundException(), InvalidExpiry => new LitterboxInvalidExpiry(), MissingFileParameter => new CatBoxMissingFileException(), + MissingUserHash => new CatBoxMissingUserHashException(), + MissingUrl => new CatBoxMissingUrlException(), + FileOwnershipMismatch => new CatBoxFileOwnershipException(), 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}") + _ when statusCode is >= HttpStatusCode.BadRequest and < HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Request Failure: {content}"), + _ when statusCode >= HttpStatusCode.InternalServerError => new HttpRequestException($"Generic Internal Server Error: {content}"), + _ => new UnreachableException($"Unexpected error: {content}") }; } } \ No newline at end of file diff --git a/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs b/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs new file mode 100644 index 0000000..20ed8b0 --- /dev/null +++ b/src/CatBox.NET/Requests/Album/GetAlbumRequest.cs @@ -0,0 +1,12 @@ +namespace CatBox.NET.Requests.Album; + +/// +/// Request to retrieve the list of files in an album +/// +public sealed record GetAlbumRequest +{ + /// + /// The unique identifier for the album (API value: "short") + /// + public required string AlbumId { get; init; } +} diff --git a/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs index f1f3a33..560dac1 100644 --- a/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs +++ b/src/CatBox.NET/Requests/Album/Modify/ModifyAlbumImagesRequest.cs @@ -9,5 +9,5 @@ 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; } + public required IEnumerable Files { get; init; } } \ No newline at end of file diff --git a/src/CatBox.NET/Responses/Album/AlbumInfo.cs b/src/CatBox.NET/Responses/Album/AlbumInfo.cs new file mode 100644 index 0000000..8623326 --- /dev/null +++ b/src/CatBox.NET/Responses/Album/AlbumInfo.cs @@ -0,0 +1,22 @@ +namespace CatBox.NET.Responses.Album; + +/// +/// Processed album information with parsed file list +/// +public sealed record AlbumInfo +{ + /// Album title + public required string Title { get; init; } + + /// Album description + public required string Description { get; init; } + + /// Album ID (short code) + public required string AlbumId { get; init; } + + /// Date the album was created + public required DateOnly DateCreated { get; init; } + + /// List of file names in the album + public required string[] Files { get; init; } +} diff --git a/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs b/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs new file mode 100644 index 0000000..bc038d4 --- /dev/null +++ b/src/CatBox.NET/Responses/Album/AlbumUploadResult.cs @@ -0,0 +1,22 @@ +namespace CatBox.NET.Responses.Album; + +/// +/// Result of uploading files to an album with capacity awareness +/// +public sealed record AlbumUploadResult +{ + /// The album URL after successful modification, or null if no files were uploaded + public required string? AlbumUrl { get; init; } + + /// Number of files successfully uploaded and added to the album + public required int FilesUploaded { get; init; } + + /// Number of files that were skipped due to album capacity limit + public required int FilesSkipped { get; init; } + + /// Remaining capacity in the album after this operation + public required int RemainingCapacity { get; init; } + + /// Whether the album has reached its maximum capacity of 500 files + public bool ReachedLimit => RemainingCapacity == 0; +} diff --git a/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs b/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs new file mode 100644 index 0000000..2a2b7eb --- /dev/null +++ b/src/CatBox.NET/Responses/Album/GetAlbumApiResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace CatBox.NET.Responses.Album; + +/// +/// Raw API response from getalbum endpoint +/// +internal sealed record GetAlbumApiResponse +{ + [JsonPropertyName("data")] + public required AlbumApiData? Data { get; init; } + + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("status")] + public int Status { get; init; } +} + +internal sealed record AlbumApiData +{ + [JsonPropertyName("files")] + public required string Files { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } + + [JsonPropertyName("short")] + public required string Short { get; init; } + + [JsonPropertyName("datecreated")] + public required DateOnly DateCreated { get; init; } +} diff --git a/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs b/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs new file mode 100644 index 0000000..f64440d --- /dev/null +++ b/src/CatBox.NET/Responses/CatBoxApiErrorResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace CatBox.NET.Responses; + +/// +/// Generic CatBox API error response format +/// +internal sealed record CatBoxApiErrorResponse +{ + [JsonPropertyName("data")] + public CatBoxApiErrorData? Data { get; init; } + + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("status")] + public int Status { get; init; } +} + +internal sealed record CatBoxApiErrorData +{ + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("endpoint")] + public string? Endpoint { get; init; } + + [JsonPropertyName("method")] + public string? Method { get; init; } +} diff --git a/src/CatBox.NET/Responses/CatBoxJsonContext.cs b/src/CatBox.NET/Responses/CatBoxJsonContext.cs new file mode 100644 index 0000000..e7ea572 --- /dev/null +++ b/src/CatBox.NET/Responses/CatBoxJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using CatBox.NET.Responses.Album; + +namespace CatBox.NET.Responses; + +[JsonSerializable(typeof(GetAlbumApiResponse))] +[JsonSerializable(typeof(CatBoxApiErrorResponse))] +internal sealed partial class CatBoxJsonContext : JsonSerializerContext; diff --git a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs index a323e4b..3792f99 100644 --- a/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs +++ b/tests/CatBox.Tests/CatBoxClientIntegrationTests.cs @@ -59,7 +59,7 @@ public async Task OneTimeTearDown() // Delete albums first (they reference files) if (_createdAlbums.Count > 0) { - TestContext.WriteLine($"Cleaning up {_createdAlbums.Count} album(s)..."); + await TestContext.Out.WriteLineAsync($"Cleaning up {_createdAlbums.Count} album(s)..."); foreach (var albumId in _createdAlbums) { try @@ -72,11 +72,11 @@ public async Task OneTimeTearDown() Files = [] }; await _client.ModifyAlbumAsync(deleteAlbumRequest); - TestContext.WriteLine($"Deleted album: {albumId}"); + await TestContext.Out.WriteLineAsync($"Deleted album: {albumId}"); } catch (Exception ex) { - TestContext.WriteLine($"Failed to delete album {albumId}: {ex.Message}"); + await TestContext.Out.WriteLineAsync($"Failed to delete album {albumId}: {ex.Message}"); } } } @@ -84,7 +84,7 @@ public async Task OneTimeTearDown() // Then delete individual files if (_uploadedFiles.Count > 0) { - TestContext.WriteLine($"Cleaning up {_uploadedFiles.Count} file(s)..."); + await TestContext.Out.WriteLineAsync($"Cleaning up {_uploadedFiles.Count} file(s)..."); var deleteRequest = new DeleteFileRequest { UserHash = IntegrationTestConfig.UserHash!, @@ -92,12 +92,12 @@ public async Task OneTimeTearDown() }; var result = await _client.DeleteMultipleFilesAsync(deleteRequest); - TestContext.WriteLine($"Delete result: {result}"); + await TestContext.Out.WriteLineAsync($"Delete result: {result}"); } } catch (Exception ex) { - TestContext.WriteLine($"Cleanup error: {ex.Message}"); + await TestContext.Out.WriteLineAsync($"Cleanup error: {ex.Message}"); } } @@ -113,7 +113,7 @@ private void TrackUploadedFile(string? url) if (!_uploadedFiles.Contains(fileName)) { _uploadedFiles.Add(fileName); - TestContext.WriteLine($"Tracked file for cleanup: {fileName}"); + TestContext.Out.WriteLine($"Tracked file for cleanup: {fileName}"); } } } @@ -130,7 +130,7 @@ private void TrackCreatedAlbum(string? albumUrl) if (!_createdAlbums.Contains(albumId)) { _createdAlbums.Add(albumId); - TestContext.WriteLine($"Tracked album for cleanup: {albumId}"); + TestContext.Out.WriteLine($"Tracked album for cleanup: {albumId}"); } } } @@ -161,7 +161,7 @@ public async Task UploadFilesAsync_WithFileFromDisk_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded file URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded file URL: {results[0]}"); } [Test] @@ -195,7 +195,7 @@ public async Task UploadFilesAsStreamAsync_WithMemoryStream_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded stream URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded stream URL: {results[0]}"); } [Test] @@ -221,7 +221,7 @@ public async Task UploadFilesAsUrlAsync_WithPublicUrl_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://files.catbox.moe/"); - TestContext.WriteLine($"Uploaded from URL: {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded from URL: {results[0]}"); } [Test] @@ -264,7 +264,7 @@ public async Task CreateAlbumAsync_WithUploadedFiles_Succeeds() // Assert albumUrl.ShouldNotBeNullOrWhiteSpace(); albumUrl.ShouldStartWith("https://catbox.moe/c/"); - TestContext.WriteLine($"Created album: {albumUrl}"); + await TestContext.Out.WriteLineAsync($"Created album: {albumUrl}"); } [Test] @@ -320,7 +320,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() }; var addResult = await _client.ModifyAlbumAsync(addRequest); - TestContext.WriteLine($"Add to album result: {addResult}"); + await TestContext.Out.WriteLineAsync($"Add to album result: {addResult}"); // Act - Remove file from album var removeRequest = new ModifyAlbumImagesRequest @@ -332,7 +332,7 @@ public async Task ModifyAlbumAsync_AddAndRemoveFiles_Succeeds() }; var removeResult = await _client.ModifyAlbumAsync(removeRequest); - TestContext.WriteLine($"Remove from album result: {removeResult}"); + await TestContext.Out.WriteLineAsync($"Remove from album result: {removeResult}"); // Assert addResult.ShouldNotBeNullOrWhiteSpace(); @@ -371,6 +371,6 @@ public async Task DeleteMultipleFilesAsync_WithUploadedFiles_Succeeds() // Assert result.ShouldNotBeNullOrWhiteSpace(); - TestContext.WriteLine($"Delete result: {result}"); + await TestContext.Out.WriteLineAsync($"Delete result: {result}"); } } diff --git a/tests/CatBox.Tests/CatBoxClientTests.cs b/tests/CatBox.Tests/CatBoxClientTests.cs index 6e1bd09..e7ee725 100644 --- a/tests/CatBox.Tests/CatBoxClientTests.cs +++ b/tests/CatBox.Tests/CatBoxClientTests.cs @@ -250,7 +250,7 @@ public async Task UploadFilesAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => @@ -509,7 +509,7 @@ public async Task UploadFilesAsUrlAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => diff --git a/tests/CatBox.Tests/CommonTests.cs b/tests/CatBox.Tests/CommonTests.cs index 1951ab5..bba3ae9 100644 --- a/tests/CatBox.Tests/CommonTests.cs +++ b/tests/CatBox.Tests/CommonTests.cs @@ -45,78 +45,39 @@ public void IsFileExtensionValid_WithValidExtensions_ReturnsTrue(string extensio } [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithNullRequest_ThrowsArgumentNullException() + public void IfAlbumCreationRequestIsInvalid_WithNullRequest_ThrowsArgumentNullException() { // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(null!)); + Should.Throw(() => Throw.IfAlbumCreationRequestIsInvalid(null!)); } - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithNullTitle_ThrowsArgumentException() + private static IEnumerable InvalidAlbumCreationRequestCases() { - // Arrange - var request = new RemoteCreateAlbumRequest - { - Title = null!, - Description = "Test Description", - UserHash = "test-hash", - Files = ["file1.jpg"] - }; - - // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + yield return new TestCaseData(null, "Test Description").SetName("Null Title"); + yield return new TestCaseData(" ", "Test Description").SetName("Whitespace Title"); + yield return new TestCaseData("Test Title", null).SetName("Null Description"); + yield return new TestCaseData("Test Title", " ").SetName("Whitespace Description"); } - [Test] - public void ThrowIfAlbumCreationRequestIsInvalid_WithWhitespaceTitle_ThrowsArgumentException() + [TestCaseSource(nameof(InvalidAlbumCreationRequestCases))] + public void IfAlbumCreationRequestIsInvalid_WithInvalidRequest_ThrowsArgumentException( + string? title, string? description) { // Arrange var request = new RemoteCreateAlbumRequest { - Title = " ", - Description = "Test Description", + Title = title!, + Description = description!, UserHash = "test-hash", Files = ["file1.jpg"] }; // Act & Assert - Should.Throw(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + Should.Throw(() => Throw.IfAlbumCreationRequestIsInvalid(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() + public void IfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow() { // Arrange var request = new RemoteCreateAlbumRequest @@ -128,7 +89,7 @@ public void ThrowIfAlbumCreationRequestIsInvalid_WithValidRequest_DoesNotThrow() }; // Act & Assert - Should.NotThrow(() => Common.ThrowIfAlbumCreationRequestIsInvalid(request)); + Should.NotThrow(() => Throw.IfAlbumCreationRequestIsInvalid(request)); } private static IEnumerable ValidAlbumRequestCases() @@ -215,4 +176,95 @@ public void IsAlbumRequestTypeValid_WithInvalidRequestType_ReturnsFalse(RequestT // Assert result.ShouldBeFalse(); } + + [TestCase("https://files.catbox.moe/abc123.jpg", "abc123.jpg")] + [TestCase("http://files.catbox.moe/xyz789.png", "xyz789.png")] + [TestCase("https://files.catbox.moe/test.gif", "test.gif")] + public void ToCatboxImageName_WithFullCatboxUrl_ExtractsFilename(string url, string expected) + { + url.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("abc123.jpg", "abc123.jpg")] + [TestCase("myfile.png", "myfile.png")] + public void ToCatboxImageName_WithPlainFilename_ReturnsOriginal(string input, string expected) + { + input.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("https://example.com/image.jpg")] + [TestCase("https://otherdomain.moe/file.png")] + public void ToCatboxImageName_WithNonCatboxUrl_ReturnsOriginal(string url) + { + url.ToCatboxImageName().ShouldBe(url); + } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase(" ", " ")] + public void ToCatboxImageName_WithNullOrWhitespace_ReturnsInput(string? input, string? expected) + { + input.ToCatboxImageName().ShouldBe(expected); + } + + [TestCase("https://catbox.moe/c/abc123", "abc123")] + [TestCase("http://catbox.moe/c/xyz789", "xyz789")] + [TestCase("https://catbox.moe/c/test", "test")] + public void ToAlbumShortCode_WithFullAlbumUrl_ExtractsShortCode(string url, string expected) + { + url.ToAlbumShortCode().ShouldBe(expected); + } + + [TestCase("abc123", "abc123")] + [TestCase("xyz789", "xyz789")] + public void ToAlbumShortCode_WithPlainShortCode_ReturnsOriginal(string input, string expected) + { + input.ToAlbumShortCode().ShouldBe(expected); + } + + [TestCase("https://catbox.moe/other/abc123")] + [TestCase("https://files.catbox.moe/abc123.jpg")] + [TestCase("https://example.com/c/abc123")] + public void ToAlbumShortCode_WithNonAlbumUrl_ReturnsOriginal(string url) + { + url.ToAlbumShortCode().ShouldBe(url); + } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase(" ", " ")] + public void ToAlbumShortCode_WithNullOrWhitespace_ReturnsInput(string? input, string? expected) + { + input.ToAlbumShortCode().ShouldBe(expected); + } + + [Test] + public void IfAlbumFileLimitExceeds_At501Files_Throws() + { + Should.Throw(() => Throw.IfAlbumFileLimitExceeds(501)); + } + + [Test] + public void IfAlbumFileLimitExceeds_At500Files_DoesNotThrow() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(500)); + } + + [Test] + public void IfAlbumFileLimitExceeds_At1File_DoesNotThrow() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(1)); + } + + [Test] + public void IfAlbumFileLimitExceeds_WithCustomLimit_ThrowsAtExceedingLimit() + { + Should.Throw(() => Throw.IfAlbumFileLimitExceeds(11, maxFiles: 10)); + } + + [Test] + public void IfAlbumFileLimitExceeds_WithCustomLimit_DoesNotThrowAtLimit() + { + Should.NotThrow(() => Throw.IfAlbumFileLimitExceeds(10, maxFiles: 10)); + } } diff --git a/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs index 4228559..63738d3 100644 --- a/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs +++ b/tests/CatBox.Tests/LitterboxClientIntegrationTests.cs @@ -66,7 +66,7 @@ public async Task UploadMultipleImagesAsync_WithOneHourExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (1h expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (1h expiry): {results[0]}"); } [Test] @@ -91,7 +91,7 @@ public async Task UploadMultipleImagesAsync_WithTwelveHourExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (12h expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (12h expiry): {results[0]}"); } [Test] @@ -116,7 +116,7 @@ public async Task UploadMultipleImagesAsync_WithOneDayExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (1d expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (1d expiry): {results[0]}"); } [Test] @@ -141,7 +141,7 @@ public async Task UploadMultipleImagesAsync_WithThreeDaysExpiry_Succeeds() results.Count.ShouldBe(1); results[0].ShouldNotBeNullOrWhiteSpace(); results[0].ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary file (3d expiry): {results[0]}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary file (3d expiry): {results[0]}"); } [Test] @@ -165,7 +165,7 @@ public async Task UploadImageAsync_WithMemoryStream_Succeeds() // Assert result.ShouldNotBeNullOrWhiteSpace(); result.ShouldStartWith("https://litter.catbox.moe/"); - TestContext.WriteLine($"Uploaded temporary stream: {result}"); + await TestContext.Out.WriteLineAsync($"Uploaded temporary stream: {result}"); } [Test] @@ -189,10 +189,10 @@ public async Task UploadMultipleImagesAsync_WithMultipleFiles_YieldsMultipleUrls // Assert results.Count.ShouldBe(3); results.ShouldAllBe(r => !string.IsNullOrWhiteSpace(r) && r!.StartsWith("https://litter.catbox.moe/")); - TestContext.WriteLine($"Uploaded {results.Count} temporary files"); + await TestContext.Out.WriteLineAsync($"Uploaded {results.Count} temporary files"); foreach (var url in results) { - TestContext.WriteLine($" - {url}"); + await TestContext.Out.WriteLineAsync($" - {url}"); } } } diff --git a/tests/CatBox.Tests/LitterboxClientTests.cs b/tests/CatBox.Tests/LitterboxClientTests.cs index 4d3c06e..78b0e53 100644 --- a/tests/CatBox.Tests/LitterboxClientTests.cs +++ b/tests/CatBox.Tests/LitterboxClientTests.cs @@ -272,7 +272,7 @@ public async Task UploadMultipleImagesAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => @@ -373,7 +373,7 @@ public async Task UploadImageAsync_CancellationToken_CancelsOperation() }; var cts = new CancellationTokenSource(); - cts.Cancel(); + await cts.CancelAsync(); // Act & Assert await Should.ThrowAsync(async () => await client.UploadImageAsync(request, cts.Token));