diff --git a/.editorconfig b/.editorconfig index a5cc332..220865e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -579,6 +579,8 @@ dotnet_diagnostic.S6964.severity = none # Value type property used a # Custom - Code Analyzers Rules ########################################## +dotnet_diagnostic.ATC210.max_line_length = 100 + dotnet_diagnostic.CA1014.severity = none # dotnet_diagnostic.CA1859.severity = none # diff --git a/Directory.Build.props b/Directory.Build.props index da885ed..6ec9b13 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -39,9 +39,10 @@ + - + diff --git a/README.md b/README.md index 6b23f22..6f47aac 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr - [๐Ÿ“ค POST Request with Body](#-post-request-with-body) - [๐Ÿ”— Using Path and Query Parameters](#-using-path-and-query-parameters) - [๐Ÿ“Ž File Upload (Multipart Form Data)](#-file-upload-multipart-form-data) + - [๐Ÿ“ File Upload with IFileContent](#-file-upload-with-ifilecontent) - [๐Ÿ“ค Binary Upload (Raw Stream)](#-binary-upload-raw-stream) - [๐Ÿ’พ File Download (Binary Response)](#-file-download-binary-response) - [๐ŸŒŠ Streaming Responses (IAsyncEnumerable)](#-streaming-responses-iasyncenumerable) @@ -270,6 +271,49 @@ requestBuilder.WithFiles(files); using var request = requestBuilder.Build(HttpMethod.Post); ``` +### ๐Ÿ“ File Upload with IFileContent + +For file uploads via `WithBody()`, implement the `IFileContent` interface: + +```csharp +using Atc.Rest.Client; + +public class MyFile : IFileContent +{ + public string FileName { get; init; } + public string? ContentType { get; init; } + public Stream OpenReadStream() => File.OpenRead(FileName); +} + +var requestBuilder = messageFactory.FromTemplate("/api/files/upload"); +requestBuilder.WithBody(new MyFile { FileName = "report.pdf", ContentType = "application/pdf" }); + +using var request = requestBuilder.Build(HttpMethod.Post); +using var response = await client.SendAsync(request, cancellationToken); +``` + +`WithBody()` also accepts `List` for multi-file uploads. + +> **Platform compatibility:** `WithBody()` automatically detects file-like objects that have +> a `FileName` (or `Name`) property and an `OpenReadStream()` method. This means ASP.NET Core +> `IFormFile` and Blazor `IBrowserFile` objects work without any additional packages or adapters: +> +> ```csharp +> // ASP.NET Core controller - works automatically +> public async Task Upload(IFormFile file) +> { +> requestBuilder.WithBody(file); +> } +> +> // Blazor WASM component - works automatically +> private async Task OnFileSelected(InputFileChangeEventArgs e) +> { +> requestBuilder.WithBody(e.File); +> } +> ``` +> +> For compile-time type safety, implement `IFileContent` explicitly. + ### ๐Ÿ“ค Binary Upload (Raw Stream) Upload a raw binary stream directly with `application/octet-stream` content type: @@ -552,6 +596,21 @@ public interface IMessageRequestBuilder } ``` +> **`IFileContent` interface:** +> +> ```csharp +> public interface IFileContent +> { +> string FileName { get; } +> string? ContentType { get; } +> Stream OpenReadStream(); +> } +> ``` +> +> Used with `WithBody()` for file uploads. Objects passed to `WithBody()` that have +> a `FileName`/`Name` property and an `OpenReadStream()` method are automatically detected +> and uploaded as multipart form data โ€” no explicit `IFileContent` implementation required. + #### `IMessageResponseBuilder` ```csharp diff --git a/src/Atc.Rest.Client/Atc.Rest.Client.csproj b/src/Atc.Rest.Client/Atc.Rest.Client.csproj index fb37430..3cdd94b 100644 --- a/src/Atc.Rest.Client/Atc.Rest.Client.csproj +++ b/src/Atc.Rest.Client/Atc.Rest.Client.csproj @@ -1,4 +1,4 @@ - +๏ปฟ netstandard2.0 @@ -13,12 +13,11 @@ - - - - - - + + + + + diff --git a/src/Atc.Rest.Client/Builder/HttpMessageFactory.cs b/src/Atc.Rest.Client/Builder/HttpMessageFactory.cs index 8b4ef81..b7f5cf0 100644 --- a/src/Atc.Rest.Client/Builder/HttpMessageFactory.cs +++ b/src/Atc.Rest.Client/Builder/HttpMessageFactory.cs @@ -4,20 +4,17 @@ internal class HttpMessageFactory : IHttpMessageFactory { private readonly IContractSerializer serializer; - public HttpMessageFactory( - IContractSerializer serializer) + public HttpMessageFactory(IContractSerializer serializer) { this.serializer = serializer; } - public IMessageRequestBuilder FromTemplate( - string pathTemplate) + public IMessageRequestBuilder FromTemplate(string pathTemplate) => new MessageRequestBuilder( pathTemplate, serializer); - public IMessageResponseBuilder FromResponse( - HttpResponseMessage? response) + public IMessageResponseBuilder FromResponse(HttpResponseMessage? response) => new MessageResponseBuilder( response, serializer); diff --git a/src/Atc.Rest.Client/Builder/IHttpMessageFactory.cs b/src/Atc.Rest.Client/Builder/IHttpMessageFactory.cs index baff1e3..87f35d2 100644 --- a/src/Atc.Rest.Client/Builder/IHttpMessageFactory.cs +++ b/src/Atc.Rest.Client/Builder/IHttpMessageFactory.cs @@ -17,14 +17,12 @@ public interface IHttpMessageFactory /// that will be replaced with real values passed to the /// method. /// A new . - IMessageRequestBuilder FromTemplate( - string pathTemplate); + IMessageRequestBuilder FromTemplate(string pathTemplate); /// /// Creates a message response builder from an HTTP response message. /// /// The HTTP response message to process. Can be null. /// A new . - IMessageResponseBuilder FromResponse( - HttpResponseMessage? response); + IMessageResponseBuilder FromResponse(HttpResponseMessage? response); } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs b/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs index 0813d75..4e3fb95 100644 --- a/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs +++ b/src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs @@ -17,7 +17,9 @@ public interface IMessageRequestBuilder /// Thrown when is null or whitespace. /// Thrown when is null or whitespace. /// The . - IMessageRequestBuilder WithPathParameter(string name, object? value); + IMessageRequestBuilder WithPathParameter( + string name, + object? value); /// /// Adds a value to a header parameter in the headers. @@ -26,7 +28,9 @@ public interface IMessageRequestBuilder /// Value to use as the header parameter. /// Thrown when is null or whitespace. /// The . - IMessageRequestBuilder WithHeaderParameter(string name, object? value); + IMessageRequestBuilder WithHeaderParameter( + string name, + object? value); /// /// Adds a query parameter to the created request URL. @@ -38,7 +42,9 @@ public interface IMessageRequestBuilder /// Value of the query parameter. /// Thrown when is null or whitespace. /// The . - IMessageRequestBuilder WithQueryParameter(string name, string? value); + IMessageRequestBuilder WithQueryParameter( + string name, + string? value); /// /// Adds a query parameter with multiple values to the created request URL. @@ -51,7 +57,9 @@ public interface IMessageRequestBuilder /// Collection of values for the query parameter. /// Thrown when is null or whitespace. /// The . - IMessageRequestBuilder WithQueryParameter(string name, IEnumerable? values); + IMessageRequestBuilder WithQueryParameter( + string name, + IEnumerable? values); /// /// Adds a query parameter to the created request URL. @@ -64,7 +72,9 @@ public interface IMessageRequestBuilder /// Value of the query parameter. /// Thrown when is null or whitespace. /// The . - IMessageRequestBuilder WithQueryParameter(string name, object? value); + IMessageRequestBuilder WithQueryParameter( + string name, + object? value); /// /// Adds the body of the request. @@ -87,7 +97,9 @@ public interface IMessageRequestBuilder /// The stream to send as the request body. /// Optional content type. Defaults to application/octet-stream. /// The . - IMessageRequestBuilder WithBinaryBody(Stream stream, string? contentType = null); + IMessageRequestBuilder WithBinaryBody( + Stream stream, + string? contentType = null); /// /// Builds a with the added content. @@ -102,7 +114,8 @@ public interface IMessageRequestBuilder /// /// The HTTP completion option. /// The . - IMessageRequestBuilder WithHttpCompletionOption(HttpCompletionOption completionOption); + IMessageRequestBuilder WithHttpCompletionOption( + HttpCompletionOption completionOption); /// /// Gets the HTTP completion option set for this request. @@ -117,14 +130,19 @@ public interface IMessageRequestBuilder /// The file name. /// Optional content type. Defaults to application/octet-stream. /// The . - IMessageRequestBuilder WithFile(Stream stream, string name, string fileName, string? contentType = null); + IMessageRequestBuilder WithFile( + Stream stream, + string name, + string fileName, + string? contentType = null); /// /// Adds multiple files to the multipart form data content. /// /// Collection of files with Stream, Name, FileName, and optional ContentType. /// The . - IMessageRequestBuilder WithFiles(IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files); + IMessageRequestBuilder WithFiles( + IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files); /// /// Adds a form field to the multipart form data content. @@ -132,5 +150,7 @@ public interface IMessageRequestBuilder /// The form field name. /// The form field value. /// The . - IMessageRequestBuilder WithFormField(string name, string value); + IMessageRequestBuilder WithFormField( + string name, + string value); } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs b/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs index 71cc693..8613da5 100644 --- a/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs +++ b/src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs @@ -10,8 +10,7 @@ public interface IMessageResponseBuilder /// /// The HTTP status code to treat as success. /// The . - IMessageResponseBuilder AddSuccessResponse( - HttpStatusCode statusCode); + IMessageResponseBuilder AddSuccessResponse(HttpStatusCode statusCode); /// /// Registers a status code as a success response with typed content deserialization. @@ -27,8 +26,7 @@ IMessageResponseBuilder AddSuccessResponse( /// /// The HTTP status code to treat as error. /// The . - IMessageResponseBuilder AddErrorResponse( - HttpStatusCode statusCode); + IMessageResponseBuilder AddErrorResponse(HttpStatusCode statusCode); /// /// Registers a status code as an error response with typed content deserialization. @@ -57,8 +55,7 @@ Task BuildResponseAsync( /// The cancellation token. /// An with the typed success content. Task> - BuildResponseAsync( - CancellationToken cancellationToken) + BuildResponseAsync(CancellationToken cancellationToken) where TSuccessContent : class; /// diff --git a/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs b/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs index 924823e..596b659 100644 --- a/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs +++ b/src/Atc.Rest.Client/Builder/MessageRequestBuilder.cs @@ -16,7 +16,7 @@ internal class MessageRequestBuilder : IMessageRequestBuilder private readonly Dictionary formFields; private readonly List<(Stream Stream, string Name, string FileName, string? ContentType)> streamFiles; private string? content; - private List? contentFormFiles; + private List? contentFormFiles; private (Stream Stream, string ContentType)? binaryContent; public MessageRequestBuilder( @@ -123,16 +123,23 @@ private HttpContent BuildFormFileContent(HttpRequestMessage message) { var formDataContent = new MultipartFormDataContent(); - foreach (var formFile in contentFormFiles!) + foreach (var fileContent in contentFormFiles!) { byte[] bytes; - using (var binaryReader = new BinaryReader(formFile.OpenReadStream())) + using (var stream = fileContent.OpenReadStream()) { - bytes = binaryReader.ReadBytes((int)formFile.OpenReadStream().Length); + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + bytes = memoryStream.ToArray(); } var bytesContent = new ByteArrayContent(bytes); - formDataContent.Add(bytesContent, "Request", formFile.FileName); + if (fileContent.ContentType is not null) + { + bytesContent.Headers.ContentType = MediaTypeHeaderValue.Parse(fileContent.ContentType); + } + + formDataContent.Add(bytesContent, "Request", fileContent.FileName); } message.Headers.Remove("accept"); @@ -141,8 +148,7 @@ private HttpContent BuildFormFileContent(HttpRequestMessage message) return formDataContent; } - public IMessageRequestBuilder WithBody( - TBody body) + public IMessageRequestBuilder WithBody(TBody body) { if (body is null) { @@ -151,18 +157,26 @@ public IMessageRequestBuilder WithBody( switch (body) { - case IFormFile formFile: - contentFormFiles = new List - { - formFile, - }; + case IFileContent fileContent: + contentFormFiles = [fileContent]; break; - case List formFiles: - contentFormFiles = new List(); - contentFormFiles.AddRange(formFiles); + case List fileContents: + contentFormFiles = new List(fileContents); break; default: - content = serializer.Serialize(body); + if (TryWrapAsFileContent(body, out var wrapped)) + { + contentFormFiles = [wrapped]; + } + else if (TryWrapAsFileContentList(body, out var wrappedList)) + { + contentFormFiles = wrappedList; + } + else + { + content = serializer.Serialize(body); + } + break; } @@ -308,16 +322,16 @@ public IMessageRequestBuilder WithQueryParameter( /// /// Gets the EnumMemberAttribute value for an enum member, using a cache to avoid repeated reflection. /// - private static string? GetEnumMemberValue(Type enumType, string memberName) - { - return EnumMemberCache.GetOrAdd((enumType, memberName), key => + private static string? GetEnumMemberValue( + Type enumType, + string memberName) + => EnumMemberCache.GetOrAdd((enumType, memberName), key => key.EnumType .GetTypeInfo() .DeclaredMembers .FirstOrDefault(x => x.Name == key.MemberName) ?.GetCustomAttribute(inherit: false) ?.Value); - } private Uri BuildRequestUri() { @@ -348,7 +362,8 @@ private Uri BuildRequestUri() /// These values are emitted as-is without additional URI encoding. /// Regular keys have their values URI-encoded to ensure proper escaping. /// - private static string BuildQueryKeyEqualValue(KeyValuePair pair) + private static string BuildQueryKeyEqualValue( + KeyValuePair pair) => pair.Key.StartsWith("#", StringComparison.Ordinal) ? $"{pair.Key.Replace("#", string.Empty)}={pair.Value}" : $"{pair.Key}={Uri.EscapeDataString(pair.Value)}"; @@ -418,4 +433,95 @@ public IMessageRequestBuilder WithHttpCompletionOption( HttpCompletionOption = completionOption; return this; } + + private static bool TryWrapAsFileContent( + object obj, + [NotNullWhen(true)] out IFileContent? fileContent) + { + fileContent = null; + + var type = obj.GetType(); + + var fileNameProp = type.GetProperty("FileName", typeof(string)) + ?? type.GetProperty("Name", typeof(string)); + if (fileNameProp is null) + { + return false; + } + + var openReadStreamMethod = FindOpenReadStreamMethod(type); + if (openReadStreamMethod is null) + { + return false; + } + + var contentTypeProp = type.GetProperty("ContentType", typeof(string)); + + fileContent = new ReflectedFileContent(obj, fileNameProp, contentTypeProp, openReadStreamMethod); + return true; + } + + private static bool TryWrapAsFileContentList( + object obj, + [NotNullWhen(true)] out List? fileContents) + { + fileContents = null; + + if (obj is not IEnumerable enumerable) + { + return false; + } + + List? result = null; + foreach (var item in enumerable) + { + if (item is null || !TryWrapAsFileContent(item, out var wrapped)) + { + return false; + } + + result ??= []; + result.Add(wrapped); + } + + if (result is null or { Count: 0 }) + { + return false; + } + + fileContents = result; + return true; + } + + private static MethodInfo? FindOpenReadStreamMethod(Type type) + { + // Prefer parameterless OpenReadStream() (matches IFormFile) + var method = type.GetMethod("OpenReadStream", Type.EmptyTypes); + if (method is not null && typeof(Stream).IsAssignableFrom(method.ReturnType)) + { + return method; + } + + // Fall back to OpenReadStream where all parameters are optional (matches IBrowserFile) + foreach (var candidate in type.GetMethods()) + { + if (!string.Equals(candidate.Name, "OpenReadStream", StringComparison.Ordinal)) + { + continue; + } + + if (!typeof(Stream).IsAssignableFrom(candidate.ReturnType)) + { + continue; + } + + var parameters = candidate.GetParameters(); + if (parameters.Length > 0 && parameters.All(p => p.IsOptional)) + { + return candidate; + } + } + + return null; + } } \ No newline at end of file diff --git a/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs b/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs index 4e34eab..8457063 100644 --- a/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs +++ b/src/Atc.Rest.Client/Builder/MessageResponseBuilder.cs @@ -25,19 +25,16 @@ public MessageResponseBuilder( responseCodes = []; } - private delegate object? ContentSerializerDelegate( - string content); + private delegate object? ContentSerializerDelegate(string content); - public IMessageResponseBuilder AddErrorResponse( - HttpStatusCode statusCode) + public IMessageResponseBuilder AddErrorResponse(HttpStatusCode statusCode) => AddEmptyResponse(statusCode, isSuccess: false); public IMessageResponseBuilder AddErrorResponse( HttpStatusCode statusCode) => AddTypedResponse(statusCode, isSuccess: false); - public IMessageResponseBuilder AddSuccessResponse( - HttpStatusCode statusCode) + public IMessageResponseBuilder AddSuccessResponse(HttpStatusCode statusCode) => AddEmptyResponse(statusCode, isSuccess: true); public IMessageResponseBuilder AddSuccessResponse( @@ -304,8 +301,7 @@ private static IReadOnlyDictionary> GetHeaders( return headers; } - private bool IsSuccessStatus( - HttpResponseMessage responseMessage) + private bool IsSuccessStatus(HttpResponseMessage responseMessage) => responseCodes.TryGetValue(responseMessage.StatusCode, out var isSuccess) ? isSuccess : responseMessage.IsSuccessStatusCode; @@ -327,7 +323,8 @@ private IMessageResponseBuilder AddEmptyResponse( } private IMessageResponseBuilder AddTypedResponse( - HttpStatusCode statusCode, bool isSuccess) + HttpStatusCode statusCode, + bool isSuccess) { responseSerializers[statusCode] = ( content => string.IsNullOrWhiteSpace(content) diff --git a/src/Atc.Rest.Client/Builder/ReflectedFileContent.cs b/src/Atc.Rest.Client/Builder/ReflectedFileContent.cs new file mode 100644 index 0000000..4bd5a15 --- /dev/null +++ b/src/Atc.Rest.Client/Builder/ReflectedFileContent.cs @@ -0,0 +1,56 @@ +namespace Atc.Rest.Client.Builder; + +internal sealed class ReflectedFileContent : IFileContent +{ + private readonly object target; + private readonly PropertyInfo fileNameProp; + private readonly PropertyInfo? contentTypeProp; + private readonly MethodInfo openReadStreamMethod; + + public ReflectedFileContent( + object target, + PropertyInfo fileNameProp, + PropertyInfo? contentTypeProp, + MethodInfo openReadStreamMethod) + { + this.target = target; + this.fileNameProp = fileNameProp; + this.contentTypeProp = contentTypeProp; + this.openReadStreamMethod = openReadStreamMethod; + } + + public string FileName => (string)fileNameProp.GetValue(target)!; + + public string? ContentType => (string?)contentTypeProp?.GetValue(target); + + public Stream OpenReadStream() + { + var parameters = openReadStreamMethod.GetParameters(); + object?[]? args = null; + + if (parameters.Length > 0) + { + args = new object?[parameters.Length]; + + if (parameters.Length == 2 && + parameters[0].ParameterType == typeof(long) && + parameters[1].ParameterType == typeof(CancellationToken)) + { + args[0] = long.MaxValue; + args[1] = CancellationToken.None; + } + else + { + for (var i = 0; i < parameters.Length; i++) + { + var defaultValue = parameters[i].DefaultValue; + args[i] = ReferenceEquals(defaultValue, Missing.Value) + ? Type.Missing + : defaultValue; + } + } + } + + return (Stream)openReadStreamMethod.Invoke(target, args)!; + } +} \ No newline at end of file diff --git a/src/Atc.Rest.Client/GlobalUsings.cs b/src/Atc.Rest.Client/GlobalUsings.cs index cf6bedb..1b31618 100644 --- a/src/Atc.Rest.Client/GlobalUsings.cs +++ b/src/Atc.Rest.Client/GlobalUsings.cs @@ -12,6 +12,5 @@ global using System.Text.Json.Serialization; global using Atc.Rest.Client.Builder; global using Atc.Rest.Client.Serialization; -global using Microsoft.AspNetCore.Http; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection.Extensions; \ No newline at end of file diff --git a/src/Atc.Rest.Client/IFileContent.cs b/src/Atc.Rest.Client/IFileContent.cs new file mode 100644 index 0000000..cf6bf9a --- /dev/null +++ b/src/Atc.Rest.Client/IFileContent.cs @@ -0,0 +1,13 @@ +namespace Atc.Rest.Client; + +/// +/// Represents a file to be uploaded via . +/// +public interface IFileContent +{ + string FileName { get; } + + string? ContentType { get; } + + Stream OpenReadStream(); +} \ No newline at end of file diff --git a/src/Atc.Rest.Client/RestClientDeserializationException.cs b/src/Atc.Rest.Client/RestClientDeserializationException.cs index ae64644..47b2bf4 100644 --- a/src/Atc.Rest.Client/RestClientDeserializationException.cs +++ b/src/Atc.Rest.Client/RestClientDeserializationException.cs @@ -26,7 +26,9 @@ public RestClientDeserializationException(string message) /// /// The error message. /// The inner exception that caused deserialization to fail. - public RestClientDeserializationException(string message, Exception innerException) + public RestClientDeserializationException( + string message, + Exception innerException) : base(message, innerException) { } diff --git a/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs b/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs index d70fc82..b6e06ff 100644 --- a/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs +++ b/src/Atc.Rest.Client/Serialization/DefaultJsonContractSerializer.cs @@ -33,8 +33,7 @@ public DefaultJsonContractSerializer( /// /// The object to serialize. /// A JSON string representation of the object. - public string Serialize( - object value) + public string Serialize(object value) => JsonSerializer.Serialize( value, options); @@ -45,8 +44,7 @@ public string Serialize( /// The type to deserialize to. /// The JSON string to deserialize. /// The deserialized object, or null if deserialization fails. - public T? Deserialize( - string json) + public T? Deserialize(string json) => JsonSerializer.Deserialize( json, options); @@ -57,8 +55,7 @@ public string Serialize( /// The type to deserialize to. /// The UTF-8 encoded JSON byte array. /// The deserialized object, or null if deserialization fails. - public T? Deserialize( - byte[] utf8Json) + public T? Deserialize(byte[] utf8Json) => JsonSerializer.Deserialize( utf8Json, options); @@ -84,7 +81,8 @@ public string Serialize( /// The type to deserialize to. /// The deserialized object, or null if deserialization fails. public object? Deserialize( - byte[] utf8Json, Type returnType) + byte[] utf8Json, + Type returnType) => JsonSerializer.Deserialize( utf8Json, returnType, diff --git a/src/Atc.Rest.Client/Serialization/IContractSerializer.cs b/src/Atc.Rest.Client/Serialization/IContractSerializer.cs index c3fce99..289b6e5 100644 --- a/src/Atc.Rest.Client/Serialization/IContractSerializer.cs +++ b/src/Atc.Rest.Client/Serialization/IContractSerializer.cs @@ -10,8 +10,7 @@ public interface IContractSerializer /// /// The object to serialize. /// A string representation of the object. - string Serialize( - object value); + string Serialize(object value); /// /// Deserializes a string to the specified type. @@ -19,8 +18,7 @@ string Serialize( /// The type to deserialize to. /// The string to deserialize. /// The deserialized object, or null if deserialization fails. - T? Deserialize( - string json); + T? Deserialize(string json); /// /// Deserializes a UTF-8 encoded byte array to the specified type. @@ -28,8 +26,7 @@ string Serialize( /// The type to deserialize to. /// The UTF-8 encoded byte array to deserialize. /// The deserialized object, or null if deserialization fails. - T? Deserialize( - byte[] utf8Json); + T? Deserialize(byte[] utf8Json); /// /// Deserializes a string to the specified type. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c0558fe..8d9ee2b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -53,7 +53,7 @@ - + diff --git a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj index 7792536..cef71fe 100644 --- a/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj +++ b/test/Atc.Rest.Client.Tests/Atc.Rest.Client.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs index 6892a6f..bc075b6 100644 --- a/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs +++ b/test/Atc.Rest.Client.Tests/BinaryEndpointResponseTests.cs @@ -146,23 +146,4 @@ public void ErrorContent_IsNull_WhenSuccessful() sut.IsSuccess.Should().BeTrue(); sut.ErrorContent.Should().BeNull(); } - - private sealed class TestableBinaryEndpointResponse : BinaryEndpointResponse - { - public TestableBinaryEndpointResponse( - bool isSuccess, - HttpStatusCode statusCode, - byte[]? content, - string? contentType, - string? fileName, - long? contentLength) - : base(isSuccess, statusCode, content, contentType, fileName, contentLength) - { - } - - public InvalidOperationException GetInvalidContentAccessException( - HttpStatusCode expectedStatusCode, - string propertyName) - => InvalidContentAccessException(expectedStatusCode, propertyName); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Builder/HttpMessageFactoryTests.cs b/test/Atc.Rest.Client.Tests/Builder/HttpMessageFactoryTests.cs index dcae4ed..bd031db 100644 --- a/test/Atc.Rest.Client.Tests/Builder/HttpMessageFactoryTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/HttpMessageFactoryTests.cs @@ -91,7 +91,8 @@ public void FromTemplate_AcceptsVariousTemplateFormats(string template) [InlineData(HttpStatusCode.Created)] [InlineData(HttpStatusCode.BadRequest)] [InlineData(HttpStatusCode.InternalServerError)] - public void FromResponse_AcceptsVariousStatusCodes(HttpStatusCode statusCode) + public void FromResponse_AcceptsVariousStatusCodes( + HttpStatusCode statusCode) { // Arrange var sut = CreateSut(); diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs index 4b87ff4..513567f 100644 --- a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderAdditionalTests.cs @@ -4,7 +4,8 @@ public sealed class MessageRequestBuilderAdditionalTests { private readonly IContractSerializer serializer = Substitute.For(); - private MessageRequestBuilder CreateSut(string template = "/api") => new(template, serializer); + private MessageRequestBuilder CreateSut(string template = "/api") + => new(template, serializer); [Fact] public void Build_WithNoContent_ReturnsMessageWithNullContent() diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderFileContentTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderFileContentTests.cs new file mode 100644 index 0000000..0fd0200 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderFileContentTests.cs @@ -0,0 +1,311 @@ +#pragma warning disable IDE0230 +namespace Atc.Rest.Client.Tests.Builder; + +public sealed class MessageRequestBuilderFileContentTests +{ + private readonly IContractSerializer serializer = Substitute.For(); + + private MessageRequestBuilder CreateSut(string template = "/test") + => new(template, serializer); + + [Fact] + public void WithBody_IFileContent_Single_ProducesMultipartContent() + { + // Arrange + var sut = CreateSut(); + var fileContent = new TestFileContent("report.pdf", "application/pdf", [1, 2, 3]); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + message.Content.Should().BeOfType(); + } + + [Fact] + public async Task WithBody_IFileContent_Single_ContainsFileBytes() + { + // Arrange + var sut = CreateSut(); + byte[] data = [10, 20, 30]; + var fileContent = new TestFileContent("data.bin", null, data); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = message.Content.Should().BeOfType().Subject; + var parts = multipart.ToList(); + parts.Should().HaveCount(1); + var bytes = await parts[0].ReadAsByteArrayAsync(); + bytes.Should().BeEquivalentTo(data); + } + + [Fact] + public void WithBody_IFileContent_Single_SetsAcceptToOctetStream() + { + // Arrange + var sut = CreateSut(); + var fileContent = new TestFileContent("file.txt", "text/plain", [1]); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + message.Headers.Accept.Should().Contain(h => h.MediaType == "application/octet-stream"); + } + + [Fact] + public void WithBody_IFileContent_Single_SetsContentType() + { + // Arrange + var sut = CreateSut(); + var fileContent = new TestFileContent("image.png", "image/png", [1, 2]); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var part = multipart.First(); + part.Headers.ContentType!.MediaType.Should().Be("image/png"); + } + + [Fact] + public void WithBody_IFileContentList_ProducesMultipartContent() + { + // Arrange + var sut = CreateSut(); + var files = new List + { + new TestFileContent("a.txt", "text/plain", [1]), + new TestFileContent("b.txt", "text/plain", [2]), + }; + + // Act + sut.WithBody(files); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = message.Content.Should().BeOfType().Subject; + multipart.Should().HaveCount(2); + } + + [Fact] + public void DuckTyping_IFormFileShape_ProducesMultipartContent() + { + // Arrange โ€” synthetic class matching IFormFile shape (FileName + parameterless OpenReadStream) + var sut = CreateSut(); + var formFileLike = new FormFileLike("upload.csv", "text/csv", [7, 8, 9]); + + // Act + sut.WithBody(formFileLike); + var message = sut.Build(HttpMethod.Post); + + // Assert + message.Content.Should().BeOfType(); + } + + [Fact] + public async Task DuckTyping_IFormFileShape_ContainsCorrectBytes() + { + // Arrange + var sut = CreateSut(); + byte[] data = [7, 8, 9]; + var formFileLike = new FormFileLike("upload.csv", "text/csv", data); + + // Act + sut.WithBody(formFileLike); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var bytes = await multipart.First().ReadAsByteArrayAsync(); + bytes.Should().BeEquivalentTo(data); + } + + [Fact] + public void DuckTyping_IBrowserFileShape_ProducesMultipartContent() + { + // Arrange โ€” synthetic class matching IBrowserFile shape (Name + OpenReadStream(long, CancellationToken)) + var sut = CreateSut(); + var browserFileLike = new BrowserFileLike("photo.jpg", "image/jpeg", [4, 5, 6]); + + // Act + sut.WithBody(browserFileLike); + var message = sut.Build(HttpMethod.Post); + + // Assert + message.Content.Should().BeOfType(); + } + + [Fact] + public async Task DuckTyping_IBrowserFileShape_ContainsCorrectBytes() + { + // Arrange + var sut = CreateSut(); + byte[] data = [4, 5, 6]; + var browserFileLike = new BrowserFileLike("photo.jpg", "image/jpeg", data); + + // Act + sut.WithBody(browserFileLike); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var bytes = await multipart.First().ReadAsByteArrayAsync(); + bytes.Should().BeEquivalentTo(data); + } + + [Fact] + public void DuckTyping_ListOfFormFileLike_ProducesMultipartContent() + { + // Arrange + var sut = CreateSut(); + var files = new List + { + new("a.txt", "text/plain", [1]), + new("b.txt", "text/plain", [2]), + }; + + // Act + sut.WithBody(files); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = message.Content.Should().BeOfType().Subject; + multipart.Should().HaveCount(2); + } + + [Fact] + public void PlainObject_StillJsonSerializes() + { + // Arrange + var sut = CreateSut(); + var poco = new { Name = "test", Value = 42 }; + + // Act + sut.WithBody(poco); + sut.Build(HttpMethod.Post); + + // Assert + serializer.Received(1).Serialize(poco); + } + + [Fact] + public void DuckTyping_ObjectWithoutOpenReadStream_FallsBackToJson() + { + // Arrange โ€” has FileName but no OpenReadStream + var sut = CreateSut(); + var notAFile = new { FileName = "trick.txt" }; + + // Act + sut.WithBody(notAFile); + sut.Build(HttpMethod.Post); + + // Assert + serializer.Received(1).Serialize(notAFile); + } + + [Fact] + public void DuckTyping_IBrowserFileShape_PassesLongMaxValueAsMaxAllowedSize() + { + // Arrange + var sut = CreateSut(); + var browserFile = new CapturingBrowserFileLike("doc.pdf", "application/pdf", [1, 2, 3]); + + // Act + sut.WithBody(browserFile); + sut.Build(HttpMethod.Post); + + // Assert โ€” ReflectedFileContent must pass long.MaxValue, not the 512000 default + browserFile.CapturedMaxAllowedSize.Should().Be(long.MaxValue); + } + + [Fact] + public void DuckTyping_IBrowserFileShape_PassesCancellationTokenNone() + { + // Arrange + var sut = CreateSut(); + var browserFile = new CapturingBrowserFileLike("doc.pdf", "application/pdf", [1, 2, 3]); + + // Act + sut.WithBody(browserFile); + sut.Build(HttpMethod.Post); + + // Assert + browserFile.CapturedCancellationToken.Should().Be(CancellationToken.None); + } + + [Fact] + public void WithBody_IFileContent_NonSeekableStream_ProducesMultipartContent() + { + // Arrange + var sut = CreateSut(); + var fileContent = new NonSeekableFileContent("stream.bin", "application/octet-stream", [10, 20, 30]); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert โ€” CopyTo must work even when the stream does not support Length/Position + message.Content.Should().BeOfType(); + } + + [Fact] + public async Task WithBody_IFileContent_NonSeekableStream_ContainsCorrectBytes() + { + // Arrange + var sut = CreateSut(); + byte[] data = [10, 20, 30]; + var fileContent = new NonSeekableFileContent("stream.bin", "application/octet-stream", data); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var bytes = await multipart.First().ReadAsByteArrayAsync(); + bytes.Should().BeEquivalentTo(data); + } + + [Fact] + public async Task WithBody_IFileContent_EmptyStream_ProducesEmptyContent() + { + // Arrange + var sut = CreateSut(); + var fileContent = new NonSeekableFileContent("empty.bin", null, []); + + // Act + sut.WithBody(fileContent); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var bytes = await multipart.First().ReadAsByteArrayAsync(); + bytes.Should().BeEmpty(); + } + + [Fact] + public async Task DuckTyping_NonSeekableBrowserFileLike_ContainsCorrectBytes() + { + // Arrange โ€” end-to-end: duck-typing + long.MaxValue + non-seekable stream + var sut = CreateSut(); + byte[] data = [99, 100, 101, 102]; + var browserFile = new NonSeekableBrowserFileLike("blob.dat", "application/octet-stream", data); + + // Act + sut.WithBody(browserFile); + var message = sut.Build(HttpMethod.Post); + + // Assert + var multipart = (MultipartFormDataContent)message.Content!; + var bytes = await multipart.First().ReadAsByteArrayAsync(); + bytes.Should().BeEquivalentTo(data); + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs index 78cc5bb..31fef46 100644 --- a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderMultipartTests.cs @@ -4,7 +4,8 @@ public sealed class MessageRequestBuilderMultipartTests { private readonly IContractSerializer serializer = Substitute.For(); - private MessageRequestBuilder CreateSut(string template = "/test") => new(template, serializer); + private MessageRequestBuilder CreateSut(string template = "/test") + => new(template, serializer); [Fact] public void WithFile_AddsFileToRequest() diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderTests.cs index c02feb2..9f3429c 100644 --- a/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/MessageRequestBuilderTests.cs @@ -4,8 +4,7 @@ public sealed class MessageRequestBuilderTests { private readonly IContractSerializer serializer = Substitute.For(); - private MessageRequestBuilder CreateSut( - string? pathTemplate = null) + private MessageRequestBuilder CreateSut(string? pathTemplate = null) => new(pathTemplate ?? "api/", serializer); [Fact] @@ -134,7 +133,8 @@ public void Should_Replace_Query_Parameters( [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Replace_Query_Parameters_With_Enum_Member_Value(string template) + public void Should_Replace_Query_Parameters_With_Enum_Member_Value( + string template) { const OperatorRole operatorRole = OperatorRole.Owner; var sut = CreateSut(template); @@ -151,7 +151,8 @@ public void Should_Replace_Query_Parameters_With_Enum_Member_Value(string templa [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Replace_Query_Parameters_With_Enum_Without_EnumMember_Attribute(string template) + public void Should_Replace_Query_Parameters_With_Enum_Without_EnumMember_Attribute( + string template) { // OperatorRole.None does not have an EnumMember attribute, so it should fall back to ToString() const OperatorRole operatorRole = OperatorRole.None; @@ -205,7 +206,8 @@ public void Should_Replace_Query_Parameters_With_DateTime(string template) [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Replace_Query_Parameters_With_DateTimeOffset(string template) + public void Should_Replace_Query_Parameters_With_DateTimeOffset( + string template) { var from = DateTimeOffset.UtcNow; @@ -313,8 +315,7 @@ public void Should_Replace_Query_Parameters_With_ListOfItems3( [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Omit_Query_Parameters_With_Empty_Array( - string template) + public void Should_Omit_Query_Parameters_With_Empty_Array(string template) { var sut = CreateSut(template); @@ -330,8 +331,7 @@ public void Should_Omit_Query_Parameters_With_Empty_Array( [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Omit_Query_Parameters_With_Empty_List( - string template) + public void Should_Omit_Query_Parameters_With_Empty_List(string template) { var sut = CreateSut(template); @@ -408,7 +408,8 @@ public async Task Should_Include_Body(string content) [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Replace_Query_Parameters_With_Array_Containing_Special_Characters(string template) + public void Should_Replace_Query_Parameters_With_Array_Containing_Special_Characters( + string template) { var sut = CreateSut(template); @@ -425,7 +426,8 @@ public void Should_Replace_Query_Parameters_With_Array_Containing_Special_Charac [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Replace_Query_Parameters_With_Single_Item_Array(string template) + public void Should_Replace_Query_Parameters_With_Single_Item_Array( + string template) { var sut = CreateSut(template); @@ -443,7 +445,8 @@ public void Should_Replace_Query_Parameters_With_Single_Item_Array(string templa [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Handle_Query_Parameters_With_Nullable_Enum(string template) + public void Should_Handle_Query_Parameters_With_Nullable_Enum( + string template) { OperatorRole? nullableRole = OperatorRole.Admin; var sut = CreateSut(template); @@ -460,7 +463,8 @@ public void Should_Handle_Query_Parameters_With_Nullable_Enum(string template) [Theory] [InlineAutoNSubstituteData("/api")] - public void Should_Omit_Query_Parameters_With_Null_Nullable_Enum(string template) + public void Should_Omit_Query_Parameters_With_Null_Nullable_Enum( + string template) { OperatorRole? nullableRole = null; var sut = CreateSut(template); diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderBinaryTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderBinaryTests.cs index c2460a5..c522a2e 100644 --- a/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderBinaryTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderBinaryTests.cs @@ -96,7 +96,9 @@ public async Task BuildBinaryResponseAsync_WithContentLength_ReturnsContentLengt [InlineData(HttpStatusCode.BadRequest, false)] [InlineData(HttpStatusCode.NotFound, false)] [InlineData(HttpStatusCode.InternalServerError, false)] - public async Task BuildBinaryResponseAsync_RespectsHttpStatusCode(HttpStatusCode statusCode, bool expectedSuccess) + public async Task BuildBinaryResponseAsync_RespectsHttpStatusCode( + HttpStatusCode statusCode, + bool expectedSuccess) { // Arrange using var response = new HttpResponseMessage(statusCode) @@ -207,7 +209,9 @@ public async Task BuildStreamBinaryResponseAsync_WithContentLength_ReturnsConten [InlineData(HttpStatusCode.BadRequest, false)] [InlineData(HttpStatusCode.Unauthorized, false)] [InlineData(HttpStatusCode.InternalServerError, false)] - public async Task BuildStreamBinaryResponseAsync_RespectsHttpStatusCode(HttpStatusCode statusCode, bool expectedSuccess) + public async Task BuildStreamBinaryResponseAsync_RespectsHttpStatusCode( + HttpStatusCode statusCode, + bool expectedSuccess) { // Arrange using var response = new HttpResponseMessage(statusCode) diff --git a/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderStreamingTests.cs b/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderStreamingTests.cs index 1d6606d..a17f04e 100644 --- a/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderStreamingTests.cs +++ b/test/Atc.Rest.Client.Tests/Builder/MessageResponseBuilderStreamingTests.cs @@ -223,7 +223,8 @@ public async Task BuildStreamingResponseAsync_SuccessResponse_YieldsItems() [InlineData(HttpStatusCode.OK)] [InlineData(HttpStatusCode.Created)] [InlineData(HttpStatusCode.Accepted)] - public async Task BuildStreamingResponseAsync_SuccessStatusCodes_YieldItems(HttpStatusCode statusCode) + public async Task BuildStreamingResponseAsync_SuccessStatusCodes_YieldItems( + HttpStatusCode statusCode) { // Arrange using var response = new HttpResponseMessage(statusCode) @@ -252,7 +253,8 @@ public async Task BuildStreamingResponseAsync_SuccessStatusCodes_YieldItems(Http [InlineData(HttpStatusCode.BadRequest)] [InlineData(HttpStatusCode.NotFound)] [InlineData(HttpStatusCode.InternalServerError)] - public async Task BuildStreamingResponseAsync_ErrorStatusCodes_YieldNoItems(HttpStatusCode statusCode) + public async Task BuildStreamingResponseAsync_ErrorStatusCodes_YieldNoItems( + HttpStatusCode statusCode) { // Arrange using var response = new HttpResponseMessage(statusCode) diff --git a/test/Atc.Rest.Client.Tests/EndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/EndpointResponseTests.cs index 9d87de2..8bec002 100644 --- a/test/Atc.Rest.Client.Tests/EndpointResponseTests.cs +++ b/test/Atc.Rest.Client.Tests/EndpointResponseTests.cs @@ -419,23 +419,4 @@ public void Content_ReturnsRawStringContent() // Assert sut.Content.Should().Be(expectedContent); } - - private sealed class TestableEndpointResponse : EndpointResponse - { - public TestableEndpointResponse( - bool isSuccess, - HttpStatusCode statusCode, - string content, - object? contentObject, - IReadOnlyDictionary> headers) - : base(isSuccess, statusCode, content, contentObject, headers) - { - } - - public InvalidOperationException GetInvalidContentAccessException( - HttpStatusCode expectedStatusCode, - string propertyName) - where TExpected : class - => InvalidContentAccessException(expectedStatusCode, propertyName); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/GlobalUsings.cs b/test/Atc.Rest.Client.Tests/GlobalUsings.cs index c420f15..b43cd44 100644 --- a/test/Atc.Rest.Client.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.Client.Tests/GlobalUsings.cs @@ -3,8 +3,10 @@ global using System.Net.Http.Headers; global using System.Runtime.Serialization; global using System.Text.Json; +global using System.Text.Json.Serialization; global using Atc.Rest.Client; global using Atc.Rest.Client.Builder; global using Atc.Rest.Client.Options; global using Atc.Rest.Client.Serialization; +global using Atc.Rest.Client.Tests.TestTypes; global using Microsoft.Extensions.DependencyInjection; \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Options/AtcRestClientOptionsTests.cs b/test/Atc.Rest.Client.Tests/Options/AtcRestClientOptionsTests.cs index 264de4c..b12511c 100644 --- a/test/Atc.Rest.Client.Tests/Options/AtcRestClientOptionsTests.cs +++ b/test/Atc.Rest.Client.Tests/Options/AtcRestClientOptionsTests.cs @@ -60,11 +60,4 @@ public void DerivedClass_CanOverrideProperties() sut.BaseAddress.Should().Be(new Uri("https://override.example.com")); sut.Timeout.Should().Be(TimeSpan.FromMinutes(2)); } - - private sealed class DerivedOptions : AtcRestClientOptions - { - public override Uri? BaseAddress { get; set; } = new Uri("https://override.example.com"); - - public override TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(2); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs index db7556d..9bd1fae 100644 --- a/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs +++ b/test/Atc.Rest.Client.Tests/Options/ServiceCollectionExtensionsTests.cs @@ -366,9 +366,4 @@ public void AddAtcRestClient_WithHttpsAndHttpBaseAddresses_BothWork() factory.CreateClient("SecureClient").BaseAddress.Should().Be(httpsAddress); factory.CreateClient("InsecureClient").BaseAddress.Should().Be(httpAddress); } - - [SuppressMessage("Major Code Smell", "S2094:Classes should not be empty", Justification = "Test helper type")] - private sealed class TestOptions : AtcRestClientOptions - { - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Serialization/DefaultJsonContractSerializerTests.cs b/test/Atc.Rest.Client.Tests/Serialization/DefaultJsonContractSerializerTests.cs index ce97da5..eae7c47 100644 --- a/test/Atc.Rest.Client.Tests/Serialization/DefaultJsonContractSerializerTests.cs +++ b/test/Atc.Rest.Client.Tests/Serialization/DefaultJsonContractSerializerTests.cs @@ -1,6 +1,5 @@ namespace Atc.Rest.Client.Tests.Serialization; -[SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Test helper types")] public sealed class DefaultJsonContractSerializerTests { private readonly DefaultJsonContractSerializer sut = new(); @@ -433,29 +432,4 @@ public void Deserialize_PreservesUnicodeCharacters() result.Should().NotBeNull(); result!.Name.Should().Be("ๆ—ฅๆœฌ่ชžใƒ†ใ‚นใƒˆ"); } - - public sealed record TestModel(string Name, int Value); - - public sealed record StatusContainer(TestStatus Status); - - public enum TestStatus - { - Inactive, - Active, - Pending, - } - - public sealed class CircularModel - { - public CircularModel? Self { get; set; } - } - - public sealed record DateTimeModel(DateTimeOffset Timestamp); - - public sealed record NestedModel(TestModel? Parent, TestModel? Child); - - [SuppressMessage("Major Code Smell", "S2094:Classes should not be empty", Justification = "Test helper type")] - public sealed class EmptyModel - { - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Serialization/JsonSerializerOptionsExtensionsTests.cs b/test/Atc.Rest.Client.Tests/Serialization/JsonSerializerOptionsExtensionsTests.cs index d18b06c..eca6d4f 100644 --- a/test/Atc.Rest.Client.Tests/Serialization/JsonSerializerOptionsExtensionsTests.cs +++ b/test/Atc.Rest.Client.Tests/Serialization/JsonSerializerOptionsExtensionsTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Atc.Rest.Client.Tests.Serialization; public sealed class JsonSerializerOptionsExtensionsTests @@ -141,22 +139,4 @@ public void WithoutConverter_SourceRemainsUnchanged() source.Converters.Should().HaveCount(1); source.Converters[0].Should().BeSameAs(converter); } - - private sealed class CustomTestConverter : JsonConverter - { - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetString(); - - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - => writer.WriteStringValue(value); - } - - private sealed class AnotherTestConverter : JsonConverter - { - public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetInt32(); - - public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) - => writer.WriteNumberValue(value); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs index 930b690..588ba4d 100644 --- a/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs +++ b/test/Atc.Rest.Client.Tests/StreamBinaryEndpointResponseTests.cs @@ -206,23 +206,4 @@ public void ErrorContent_IsNull_WhenSuccessful() sut.IsSuccess.Should().BeTrue(); sut.ErrorContent.Should().BeNull(); } - - private sealed class TestableStreamBinaryEndpointResponse : StreamBinaryEndpointResponse - { - public TestableStreamBinaryEndpointResponse( - bool isSuccess, - HttpStatusCode statusCode, - Stream? contentStream, - string? contentType, - string? fileName, - long? contentLength) - : base(isSuccess, statusCode, contentStream, contentType, fileName, contentLength) - { - } - - public InvalidOperationException GetInvalidContentAccessException( - HttpStatusCode expectedStatusCode, - string propertyName) - => InvalidContentAccessException(expectedStatusCode, propertyName); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/StreamingEndpointResponseTests.cs b/test/Atc.Rest.Client.Tests/StreamingEndpointResponseTests.cs index 7558b1c..6895374 100644 --- a/test/Atc.Rest.Client.Tests/StreamingEndpointResponseTests.cs +++ b/test/Atc.Rest.Client.Tests/StreamingEndpointResponseTests.cs @@ -161,22 +161,4 @@ public void InvalidContentAccessException_ContainsPropertyName() // Assert exception.Message.Should().Contain("OKContent"); } - - private sealed class TestableStreamingEndpointResponse : StreamingEndpointResponse - { - public TestableStreamingEndpointResponse( - bool isSuccess, - HttpStatusCode statusCode, - IAsyncEnumerable? content, - string? errorContent, - HttpResponseMessage? httpResponse) - : base(isSuccess, statusCode, content, errorContent, httpResponse) - { - } - - public InvalidOperationException GetInvalidContentAccessException( - HttpStatusCode expectedStatusCode, - string propertyName) - => InvalidContentAccessException(expectedStatusCode, propertyName); - } } \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/AnotherTestConverter.cs b/test/Atc.Rest.Client.Tests/TestTypes/AnotherTestConverter.cs new file mode 100644 index 0000000..ed4bcba --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/AnotherTestConverter.cs @@ -0,0 +1,16 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class AnotherTestConverter : JsonConverter +{ + public override int Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => reader.GetInt32(); + + public override void Write( + Utf8JsonWriter writer, + int value, + JsonSerializerOptions options) + => writer.WriteNumberValue(value); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/BadResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/BadResponse.cs similarity index 61% rename from test/Atc.Rest.Client.Tests/BadResponse.cs rename to test/Atc.Rest.Client.Tests/TestTypes/BadResponse.cs index 562559b..35941b0 100644 --- a/test/Atc.Rest.Client.Tests/BadResponse.cs +++ b/test/Atc.Rest.Client.Tests/TestTypes/BadResponse.cs @@ -1,4 +1,4 @@ -namespace Atc.Rest.Client.Tests; +namespace Atc.Rest.Client.Tests.TestTypes; public class BadResponse { diff --git a/test/Atc.Rest.Client.Tests/TestTypes/BrowserFileLike.cs b/test/Atc.Rest.Client.Tests/TestTypes/BrowserFileLike.cs new file mode 100644 index 0000000..cedffe9 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/BrowserFileLike.cs @@ -0,0 +1,28 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// Mimics the shape of IBrowserFile: Name property + OpenReadStream(long, CancellationToken) with defaults. +/// +[SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Mimics IBrowserFile.OpenReadStream() method shape for duck-typing test")] +internal sealed class BrowserFileLike +{ + private readonly byte[] data; + + public BrowserFileLike( + string name, + string contentType, + byte[] data) + { + Name = name; + ContentType = contentType; + this.data = data; + } + + public string Name { get; } + + public string ContentType { get; } + + public Stream OpenReadStream( + long maxAllowedSize = 512000, + CancellationToken cancellationToken = default) => new MemoryStream(data); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/CapturingBrowserFileLike.cs b/test/Atc.Rest.Client.Tests/TestTypes/CapturingBrowserFileLike.cs new file mode 100644 index 0000000..42ffe4d --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/CapturingBrowserFileLike.cs @@ -0,0 +1,37 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// IBrowserFile duck-type shape that captures the arguments passed to OpenReadStream. +/// +[SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Mimics IBrowserFile.OpenReadStream() method shape for duck-typing test")] +internal sealed class CapturingBrowserFileLike +{ + private readonly byte[] data; + + public CapturingBrowserFileLike( + string name, + string contentType, + byte[] data) + { + Name = name; + ContentType = contentType; + this.data = data; + } + + public string Name { get; } + + public string ContentType { get; } + + public long? CapturedMaxAllowedSize { get; private set; } + + public CancellationToken? CapturedCancellationToken { get; private set; } + + public Stream OpenReadStream( + long maxAllowedSize = 512000, + CancellationToken cancellationToken = default) + { + CapturedMaxAllowedSize = maxAllowedSize; + CapturedCancellationToken = cancellationToken; + return new MemoryStream(data); + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/CircularModel.cs b/test/Atc.Rest.Client.Tests/TestTypes/CircularModel.cs new file mode 100644 index 0000000..63f72ae --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/CircularModel.cs @@ -0,0 +1,6 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public sealed class CircularModel +{ + public CircularModel? Self { get; set; } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/CustomTestConverter.cs b/test/Atc.Rest.Client.Tests/TestTypes/CustomTestConverter.cs new file mode 100644 index 0000000..4318ca4 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/CustomTestConverter.cs @@ -0,0 +1,16 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class CustomTestConverter : JsonConverter +{ + public override string? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => reader.GetString(); + + public override void Write( + Utf8JsonWriter writer, + string value, + JsonSerializerOptions options) + => writer.WriteStringValue(value); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/DateTimeModel.cs b/test/Atc.Rest.Client.Tests/TestTypes/DateTimeModel.cs new file mode 100644 index 0000000..6ddba1a --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/DateTimeModel.cs @@ -0,0 +1,3 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public sealed record DateTimeModel(DateTimeOffset Timestamp); \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/DerivedOptions.cs b/test/Atc.Rest.Client.Tests/TestTypes/DerivedOptions.cs new file mode 100644 index 0000000..204cf5e --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/DerivedOptions.cs @@ -0,0 +1,8 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class DerivedOptions : AtcRestClientOptions +{ + public override Uri? BaseAddress { get; set; } = new Uri("https://override.example.com"); + + public override TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(2); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/EmptyModel.cs b/test/Atc.Rest.Client.Tests/TestTypes/EmptyModel.cs new file mode 100644 index 0000000..28cbb04 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/EmptyModel.cs @@ -0,0 +1,6 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +[SuppressMessage("Major Code Smell", "S2094:Classes should not be empty", Justification = "Test helper type")] +public sealed class EmptyModel +{ +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/FormFileLike.cs b/test/Atc.Rest.Client.Tests/TestTypes/FormFileLike.cs new file mode 100644 index 0000000..88e314b --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/FormFileLike.cs @@ -0,0 +1,26 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// Mimics the shape of IFormFile: FileName property + parameterless OpenReadStream(). +/// +[SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Mimics IFormFile.OpenReadStream() method shape for duck-typing test")] +internal sealed class FormFileLike +{ + private readonly byte[] data; + + public FormFileLike( + string fileName, + string contentType, + byte[] data) + { + FileName = fileName; + ContentType = contentType; + this.data = data; + } + + public string FileName { get; } + + public string ContentType { get; } + + public Stream OpenReadStream() => new MemoryStream(data); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/NestedModel.cs b/test/Atc.Rest.Client.Tests/TestTypes/NestedModel.cs new file mode 100644 index 0000000..d6934a0 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/NestedModel.cs @@ -0,0 +1,3 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public sealed record NestedModel(TestModel? Parent, TestModel? Child); \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableBrowserFileLike.cs b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableBrowserFileLike.cs new file mode 100644 index 0000000..0015024 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableBrowserFileLike.cs @@ -0,0 +1,29 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// IBrowserFile duck-type shape that returns a non-seekable stream. +/// End-to-end test of duck-typing + long.MaxValue + non-seekable stream. +/// +[SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Mimics IBrowserFile.OpenReadStream() method shape for duck-typing test")] +internal sealed class NonSeekableBrowserFileLike +{ + private readonly byte[] data; + + public NonSeekableBrowserFileLike( + string name, + string contentType, + byte[] data) + { + Name = name; + ContentType = contentType; + this.data = data; + } + + public string Name { get; } + + public string ContentType { get; } + + public Stream OpenReadStream( + long maxAllowedSize = 512000, + CancellationToken cancellationToken = default) => new NonSeekableStream(data); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableFileContent.cs b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableFileContent.cs new file mode 100644 index 0000000..39ec943 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableFileContent.cs @@ -0,0 +1,26 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// IFileContent implementation that returns a non-seekable stream. +/// Tests that BuildFormFileContent uses CopyTo (not stream.Length). +/// +internal sealed class NonSeekableFileContent : IFileContent +{ + private readonly byte[] data; + + public NonSeekableFileContent( + string fileName, + string? contentType, + byte[] data) + { + FileName = fileName; + ContentType = contentType; + this.data = data; + } + + public string FileName { get; } + + public string? ContentType { get; } + + public Stream OpenReadStream() => new NonSeekableStream(data); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableStream.cs b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableStream.cs new file mode 100644 index 0000000..2715123 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/NonSeekableStream.cs @@ -0,0 +1,59 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +/// +/// A stream wrapper that does not support Length, Position, or Seek. +/// Simulates the non-seekable stream returned by IBrowserFile.OpenReadStream(). +/// +internal sealed class NonSeekableStream : Stream +{ + private readonly MemoryStream inner; + + public NonSeekableStream(byte[] data) => inner = new MemoryStream(data); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read( + byte[] buffer, + int offset, + int count) + => inner.Read(buffer, offset, count); + + public override long Seek( + long offset, + SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write( + byte[] buffer, + int offset, + int count) + => throw new NotSupportedException(); + + public override void Flush() + => inner.Flush(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/Builder/OperatorRole.cs b/test/Atc.Rest.Client.Tests/TestTypes/OperatorRole.cs similarity index 76% rename from test/Atc.Rest.Client.Tests/Builder/OperatorRole.cs rename to test/Atc.Rest.Client.Tests/TestTypes/OperatorRole.cs index 85a6989..787a737 100644 --- a/test/Atc.Rest.Client.Tests/Builder/OperatorRole.cs +++ b/test/Atc.Rest.Client.Tests/TestTypes/OperatorRole.cs @@ -1,4 +1,4 @@ -namespace Atc.Rest.Client.Tests.Builder; +namespace Atc.Rest.Client.Tests.TestTypes; public enum OperatorRole { diff --git a/test/Atc.Rest.Client.Tests/TestTypes/StatusContainer.cs b/test/Atc.Rest.Client.Tests/TestTypes/StatusContainer.cs new file mode 100644 index 0000000..357454a --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/StatusContainer.cs @@ -0,0 +1,3 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public sealed record StatusContainer(TestStatus Status); \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/SuccessResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/SuccessResponse.cs similarity index 62% rename from test/Atc.Rest.Client.Tests/SuccessResponse.cs rename to test/Atc.Rest.Client.Tests/TestTypes/SuccessResponse.cs index f09e6ba..4808980 100644 --- a/test/Atc.Rest.Client.Tests/SuccessResponse.cs +++ b/test/Atc.Rest.Client.Tests/TestTypes/SuccessResponse.cs @@ -1,4 +1,4 @@ -namespace Atc.Rest.Client.Tests; +namespace Atc.Rest.Client.Tests.TestTypes; public class SuccessResponse { diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestFileContent.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestFileContent.cs new file mode 100644 index 0000000..7e89e02 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestFileContent.cs @@ -0,0 +1,22 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class TestFileContent : IFileContent +{ + private readonly byte[] data; + + public TestFileContent( + string fileName, + string? contentType, + byte[] data) + { + FileName = fileName; + ContentType = contentType; + this.data = data; + } + + public string FileName { get; } + + public string? ContentType { get; } + + public Stream OpenReadStream() => new MemoryStream(data); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestModel.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestModel.cs new file mode 100644 index 0000000..0d5a42f --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestModel.cs @@ -0,0 +1,3 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public sealed record TestModel(string Name, int Value); \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestOptions.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestOptions.cs new file mode 100644 index 0000000..caa38e3 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestOptions.cs @@ -0,0 +1,6 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +[SuppressMessage("Major Code Smell", "S2094:Classes should not be empty", Justification = "Test helper type")] +internal sealed class TestOptions : AtcRestClientOptions +{ +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestStatus.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestStatus.cs new file mode 100644 index 0000000..6e29fa0 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestStatus.cs @@ -0,0 +1,8 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +public enum TestStatus +{ + Inactive, + Active, + Pending, +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestableBinaryEndpointResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestableBinaryEndpointResponse.cs new file mode 100644 index 0000000..733ad35 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestableBinaryEndpointResponse.cs @@ -0,0 +1,20 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class TestableBinaryEndpointResponse : BinaryEndpointResponse +{ + public TestableBinaryEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + byte[]? content, + string? contentType, + string? fileName, + long? contentLength) + : base(isSuccess, statusCode, content, contentType, fileName, contentLength) + { + } + + public InvalidOperationException GetInvalidContentAccessException( + HttpStatusCode expectedStatusCode, + string propertyName) + => InvalidContentAccessException(expectedStatusCode, propertyName); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestableEndpointResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestableEndpointResponse.cs new file mode 100644 index 0000000..446ffa8 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestableEndpointResponse.cs @@ -0,0 +1,20 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class TestableEndpointResponse : EndpointResponse +{ + public TestableEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + string content, + object? contentObject, + IReadOnlyDictionary> headers) + : base(isSuccess, statusCode, content, contentObject, headers) + { + } + + public InvalidOperationException GetInvalidContentAccessException( + HttpStatusCode expectedStatusCode, + string propertyName) + where TExpected : class + => InvalidContentAccessException(expectedStatusCode, propertyName); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamBinaryEndpointResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamBinaryEndpointResponse.cs new file mode 100644 index 0000000..43b6ee3 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamBinaryEndpointResponse.cs @@ -0,0 +1,20 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class TestableStreamBinaryEndpointResponse : StreamBinaryEndpointResponse +{ + public TestableStreamBinaryEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + Stream? contentStream, + string? contentType, + string? fileName, + long? contentLength) + : base(isSuccess, statusCode, contentStream, contentType, fileName, contentLength) + { + } + + public InvalidOperationException GetInvalidContentAccessException( + HttpStatusCode expectedStatusCode, + string propertyName) + => InvalidContentAccessException(expectedStatusCode, propertyName); +} \ No newline at end of file diff --git a/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamingEndpointResponse.cs b/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamingEndpointResponse.cs new file mode 100644 index 0000000..7caafb9 --- /dev/null +++ b/test/Atc.Rest.Client.Tests/TestTypes/TestableStreamingEndpointResponse.cs @@ -0,0 +1,19 @@ +namespace Atc.Rest.Client.Tests.TestTypes; + +internal sealed class TestableStreamingEndpointResponse : StreamingEndpointResponse +{ + public TestableStreamingEndpointResponse( + bool isSuccess, + HttpStatusCode statusCode, + IAsyncEnumerable? content, + string? errorContent, + HttpResponseMessage? httpResponse) + : base(isSuccess, statusCode, content, errorContent, httpResponse) + { + } + + public InvalidOperationException GetInvalidContentAccessException( + HttpStatusCode expectedStatusCode, + string propertyName) + => InvalidContentAccessException(expectedStatusCode, propertyName); +} \ No newline at end of file diff --git a/test/Directory.Build.props b/test/Directory.Build.props index be62deb..4860211 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -18,7 +18,7 @@ - +