sprints work
This commit is contained in:
@@ -67,8 +67,8 @@ public sealed class ConnectionManagerIntegrationTests
|
||||
// Act
|
||||
var connection = connectionManager.Connections.FirstOrDefault();
|
||||
|
||||
// Assert
|
||||
connection!.Endpoints.Should().HaveCount(8);
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
connection!.Endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -27,8 +27,8 @@ public sealed class EndpointRegistryIntegrationTests
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().HaveCount(8);
|
||||
// Assert - 8 basic endpoints + 9 binding test endpoints = 17
|
||||
endpoints.Should().HaveCount(17);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -84,7 +84,7 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
});
|
||||
});
|
||||
|
||||
// Register test endpoint handlers
|
||||
// Register test endpoint handlers - basic endpoints
|
||||
builder.Services.AddScoped<EchoEndpoint>();
|
||||
builder.Services.AddScoped<GetUserEndpoint>();
|
||||
builder.Services.AddScoped<CreateUserEndpoint>();
|
||||
@@ -94,6 +94,17 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
builder.Services.AddScoped<AdminResetEndpoint>();
|
||||
builder.Services.AddScoped<QuickEndpoint>();
|
||||
|
||||
// Register test endpoint handlers - binding test endpoints
|
||||
builder.Services.AddScoped<SearchEndpoint>(); // Query params
|
||||
builder.Services.AddScoped<GetItemEndpoint>(); // Multiple path params
|
||||
builder.Services.AddScoped<HeaderTestEndpoint>(); // Header binding
|
||||
builder.Services.AddScoped<LoginEndpoint>(); // Form data
|
||||
builder.Services.AddScoped<UpdateResourceEndpoint>(); // Combined binding
|
||||
builder.Services.AddScoped<ListItemsEndpoint>(); // Pagination
|
||||
builder.Services.AddScoped<RawEchoEndpoint>(); // Raw body
|
||||
builder.Services.AddScoped<DeleteItemEndpoint>(); // DELETE with path
|
||||
builder.Services.AddScoped<PatchItemEndpoint>(); // PATCH with path + body
|
||||
|
||||
_host = builder.Build();
|
||||
|
||||
// Start the transport server first (simulates Gateway)
|
||||
@@ -193,8 +204,8 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat)
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -291,8 +302,8 @@ public sealed class MicroserviceIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
responseFrame = await channel.ToGateway.Reader.ReadAsync(cts.Token);
|
||||
|
||||
// Skip heartbeat frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat)
|
||||
// Skip heartbeat and hello frames, wait for actual response
|
||||
if (responseFrame.Type == FrameType.Heartbeat || responseFrame.Type == FrameType.Hello)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ namespace StellaOps.Router.Integration.Tests.Fixtures;
|
||||
public record EchoRequest(string Message);
|
||||
public record EchoResponse(string Echo, DateTime Timestamp);
|
||||
|
||||
public record GetUserRequest(string UserId);
|
||||
// Changed from positional record to property-based for path parameter binding support
|
||||
public record GetUserRequest
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
public record GetUserResponse(string UserId, string Name, string Email);
|
||||
|
||||
public record CreateUserRequest(string Name, string Email);
|
||||
@@ -115,10 +119,11 @@ public sealed class GetUserEndpoint : IStellaEndpoint<GetUserRequest, GetUserRes
|
||||
{
|
||||
public Task<GetUserResponse> HandleAsync(GetUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = request.UserId ?? "unknown";
|
||||
return Task.FromResult(new GetUserResponse(
|
||||
request.UserId,
|
||||
$"User-{request.UserId}",
|
||||
$"user-{request.UserId}@example.com"));
|
||||
userId,
|
||||
$"User-{userId}",
|
||||
$"user-{userId}@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +218,253 @@ public sealed class QuickEndpoint : IStellaEndpoint<EchoRequest, EchoResponse>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search endpoint demonstrating query parameter binding (FromQuery).
|
||||
/// GET /search?query=test&page=1&pageSize=20&includeDeleted=true
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/search")]
|
||||
public sealed class SearchEndpoint : IStellaEndpoint<SearchRequest, SearchResponse>
|
||||
{
|
||||
public Task<SearchResponse> HandleAsync(SearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SearchResponse(
|
||||
request.Query,
|
||||
request.Page,
|
||||
request.PageSize,
|
||||
request.IncludeDeleted,
|
||||
TotalResults: 42));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Item endpoint demonstrating path parameter binding (FromRoute).
|
||||
/// GET /categories/{categoryId}/items/{itemId}
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/categories/{categoryId}/items/{itemId}")]
|
||||
public sealed class GetItemEndpoint : IStellaEndpoint<GetItemRequest, GetItemResponse>
|
||||
{
|
||||
public Task<GetItemResponse> HandleAsync(GetItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new GetItemResponse(
|
||||
request.CategoryId,
|
||||
request.ItemId,
|
||||
Name: $"Item-{request.ItemId}-in-{request.CategoryId}",
|
||||
Price: 19.99m));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header inspection endpoint demonstrating header access (FromHeader).
|
||||
/// Uses raw endpoint to access all headers directly.
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/headers")]
|
||||
public sealed class HeaderTestEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var allHeaders = context.Headers.ToDictionary(
|
||||
h => h.Key,
|
||||
h => h.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var response = new HeaderTestResponse(
|
||||
Authorization: context.Headers.TryGetValue("Authorization", out var auth) ? auth : null,
|
||||
XRequestId: context.Headers.TryGetValue("X-Request-Id", out var reqId) ? reqId : null,
|
||||
XCustomHeader: context.Headers.TryGetValue("X-Custom-Header", out var custom) ? custom : null,
|
||||
AcceptLanguage: context.Headers.TryGetValue("Accept-Language", out var lang) ? lang : null,
|
||||
AllHeaders: allHeaders);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return Task.FromResult(new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([new KeyValuePair<string, string>("Content-Type", "application/json")]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(json))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Form data endpoint demonstrating form binding (FromForm).
|
||||
/// POST /login with application/x-www-form-urlencoded body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("POST", "/login")]
|
||||
public sealed class LoginEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public async Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var contentType = context.Headers.TryGetValue("Content-Type", out var ct) ? ct : string.Empty;
|
||||
|
||||
// Parse form data
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
bool rememberMe = false;
|
||||
|
||||
if (contentType?.Contains("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
using var reader = new StreamReader(context.Body);
|
||||
var body = await reader.ReadToEndAsync(cancellationToken);
|
||||
var formData = ParseFormData(body);
|
||||
|
||||
username = formData.GetValueOrDefault("username");
|
||||
password = formData.GetValueOrDefault("password");
|
||||
if (formData.TryGetValue("rememberMe", out var rm))
|
||||
{
|
||||
rememberMe = string.Equals(rm, "true", StringComparison.OrdinalIgnoreCase) || rm == "1";
|
||||
}
|
||||
}
|
||||
|
||||
var response = new FormDataResponse(username, password, rememberMe, contentType);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([new KeyValuePair<string, string>("Content-Type", "application/json")]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(json))
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseFormData(string body)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(body)) return result;
|
||||
|
||||
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq < 0) continue;
|
||||
|
||||
var key = Uri.UnescapeDataString(pair[..eq].Replace('+', ' '));
|
||||
var value = Uri.UnescapeDataString(pair[(eq + 1)..].Replace('+', ' '));
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined binding endpoint demonstrating path + query + body binding.
|
||||
/// PUT /resources/{resourceId}?format=json&verbose=true with JSON body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("PUT", "/resources/{resourceId}")]
|
||||
public sealed class UpdateResourceEndpoint : IStellaEndpoint<CombinedRequest, CombinedResponse>
|
||||
{
|
||||
public Task<CombinedResponse> HandleAsync(CombinedRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new CombinedResponse(
|
||||
request.ResourceId,
|
||||
request.Format,
|
||||
request.Verbose,
|
||||
request.Name,
|
||||
request.Description));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pagination endpoint demonstrating optional query parameters with defaults.
|
||||
/// GET /items?offset=0&limit=10&sortBy=name&sortOrder=asc
|
||||
/// </summary>
|
||||
[StellaEndpoint("GET", "/items")]
|
||||
public sealed class ListItemsEndpoint : IStellaEndpoint<PagedRequest, PagedResponse>
|
||||
{
|
||||
public Task<PagedResponse> HandleAsync(PagedRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new PagedResponse(
|
||||
Offset: request.Offset ?? 0,
|
||||
Limit: request.Limit ?? 20,
|
||||
SortBy: request.SortBy ?? "id",
|
||||
SortOrder: request.SortOrder ?? "asc"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw body echo endpoint for testing raw request body access.
|
||||
/// POST /raw-echo - echoes back whatever body is sent.
|
||||
/// </summary>
|
||||
[StellaEndpoint("POST", "/raw-echo")]
|
||||
public sealed class RawEchoEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public async Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(context.Body);
|
||||
var body = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var contentType = context.Headers.TryGetValue("Content-Type", out var ct) ? ct : "text/plain";
|
||||
|
||||
return new RawResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Headers = new HeaderCollection([
|
||||
new KeyValuePair<string, string>("Content-Type", contentType ?? "text/plain"),
|
||||
new KeyValuePair<string, string>("X-Echo-Length", body.Length.ToString())
|
||||
]),
|
||||
Body = new MemoryStream(Encoding.UTF8.GetBytes(body))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE endpoint with path parameter.
|
||||
/// DELETE /items/{itemId}
|
||||
/// </summary>
|
||||
[StellaEndpoint("DELETE", "/items/{itemId}")]
|
||||
public sealed class DeleteItemEndpoint : IStellaEndpoint<DeleteItemRequest, DeleteItemResponse>
|
||||
{
|
||||
public Task<DeleteItemResponse> HandleAsync(DeleteItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new DeleteItemResponse(
|
||||
ItemId: request.ItemId,
|
||||
Deleted: true,
|
||||
DeletedAt: DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteItemRequest
|
||||
{
|
||||
public string? ItemId { get; set; }
|
||||
}
|
||||
|
||||
public record DeleteItemResponse(string? ItemId, bool Deleted, DateTime DeletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// PATCH endpoint for partial updates.
|
||||
/// PATCH /items/{itemId} with JSON body.
|
||||
/// </summary>
|
||||
[StellaEndpoint("PATCH", "/items/{itemId}")]
|
||||
public sealed class PatchItemEndpoint : IStellaEndpoint<PatchItemRequest, PatchItemResponse>
|
||||
{
|
||||
public Task<PatchItemResponse> HandleAsync(PatchItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var updatedFields = new List<string>();
|
||||
if (request.Name is not null) updatedFields.Add("name");
|
||||
if (request.Price.HasValue) updatedFields.Add("price");
|
||||
|
||||
return Task.FromResult(new PatchItemResponse(
|
||||
ItemId: request.ItemId,
|
||||
Name: request.Name,
|
||||
Price: request.Price,
|
||||
UpdatedFields: updatedFields));
|
||||
}
|
||||
}
|
||||
|
||||
public record PatchItemRequest
|
||||
{
|
||||
public string? ItemId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
}
|
||||
|
||||
public record PatchItemResponse(string? ItemId, string? Name, decimal? Price, List<string> UpdatedFields);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Endpoint Discovery Provider
|
||||
@@ -226,6 +478,7 @@ public sealed class TestEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
{
|
||||
return
|
||||
[
|
||||
// Basic endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
@@ -303,6 +556,99 @@ public sealed class TestEndpointDiscoveryProvider : IEndpointDiscoveryProvider
|
||||
Path = "/quick",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(5),
|
||||
HandlerType = typeof(QuickEndpoint)
|
||||
},
|
||||
|
||||
// Query parameter binding endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/search",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(SearchEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(ListItemsEndpoint)
|
||||
},
|
||||
|
||||
// Path parameter binding endpoints
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/categories/{categoryId}/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(GetItemEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "DELETE",
|
||||
Path = "/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(DeleteItemEndpoint)
|
||||
},
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "PATCH",
|
||||
Path = "/items/{itemId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(PatchItemEndpoint)
|
||||
},
|
||||
|
||||
// Header binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/headers",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(HeaderTestEndpoint)
|
||||
},
|
||||
|
||||
// Form data binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/login",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(LoginEndpoint)
|
||||
},
|
||||
|
||||
// Combined binding endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "PUT",
|
||||
Path = "/resources/{resourceId}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(UpdateResourceEndpoint)
|
||||
},
|
||||
|
||||
// Raw body endpoint
|
||||
new Router.Common.Models.EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/raw-echo",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
HandlerType = typeof(RawEchoEndpoint)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Router.Integration.Tests.Fixtures;
|
||||
|
||||
namespace StellaOps.Router.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive tests for ASP.NET Minimal APIs-style parameter binding patterns.
|
||||
/// Tests FromQuery, FromRoute, FromHeader, FromBody, and FromForm binding across all HTTP methods.
|
||||
/// </summary>
|
||||
[Collection("Microservice Integration")]
|
||||
public sealed class ParameterBindingTests
|
||||
{
|
||||
private readonly MicroserviceIntegrationFixture _fixture;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ParameterBindingTests(MicroserviceIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
#region FromQuery - Query Parameter Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_StringParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", "test-search-term")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("test-search-term");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_IntParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("page", 5)
|
||||
.WithQuery("pageSize", 25)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Page.Should().Be(5);
|
||||
result.PageSize.Should().Be(25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_BoolParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("includeDeleted", "true")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.IncludeDeleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_MultipleParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", "widgets")
|
||||
.WithQuery("page", 3)
|
||||
.WithQuery("pageSize", 50)
|
||||
.WithQuery("includeDeleted", "false")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("widgets");
|
||||
result.Page.Should().Be(3);
|
||||
result.PageSize.Should().Be(50);
|
||||
result.IncludeDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_UrlEncodedValues_BindCorrectly()
|
||||
{
|
||||
// Arrange - Query with special characters
|
||||
var query = "hello world & test=value";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQuery("query", query)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be(query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_OptionalParameters_UseDefaults()
|
||||
{
|
||||
// Arrange & Act - No query parameters provided
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.SendAsync();
|
||||
|
||||
// Assert - Should use default values
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Offset.Should().Be(0); // Default
|
||||
result.Limit.Should().Be(20); // Default
|
||||
result.SortBy.Should().Be("id"); // Default
|
||||
result.SortOrder.Should().Be("asc"); // Default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_OverrideDefaults_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.WithQuery("offset", 100)
|
||||
.WithQuery("limit", 50)
|
||||
.WithQuery("sortBy", "name")
|
||||
.WithQuery("sortOrder", "desc")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Offset.Should().Be(100);
|
||||
result.Limit.Should().Be(50);
|
||||
result.SortBy.Should().Be("name");
|
||||
result.SortOrder.Should().Be("desc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromQuery_WithAnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act - Using anonymous object for multiple query params
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.WithQueries(new { query = "bulk-search", page = 2, pageSize = 30 })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Query.Should().Be("bulk-search");
|
||||
result.Page.Should().Be(2);
|
||||
result.PageSize.Should().Be(30);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromRoute - Path Parameter Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_SinglePathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/user-123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("user-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_MultiplePathParameters_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/categories/electronics/items/widget-456")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.CategoryId.Should().Be("electronics");
|
||||
result.ItemId.Should().Be("widget-456");
|
||||
result.Name.Should().Be("Item-widget-456-in-electronics");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_NumericPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/12345")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_GuidPathParameter_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", $"/users/{guid}")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be(guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromRoute_SpecialCharactersInPath_BindsCorrectly()
|
||||
{
|
||||
// Arrange - URL-encoded special characters
|
||||
var categoryId = "cat-with-dash";
|
||||
var itemId = "item_underscore_123";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("GET", $"/categories/{categoryId}/items/{itemId}")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.CategoryId.Should().Be(categoryId);
|
||||
result!.ItemId.Should().Be(itemId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromHeader - Header Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_AuthorizationHeader_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithAuthorization("Bearer", "test-token-12345")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Authorization.Should().Be("Bearer test-token-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_CustomHeaders_BindCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithHeader("X-Request-Id", "req-abc-123")
|
||||
.WithHeader("X-Custom-Header", "custom-value")
|
||||
.WithHeader("Accept-Language", "en-US")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.XRequestId.Should().Be("req-abc-123");
|
||||
result!.XCustomHeader.Should().Be("custom-value");
|
||||
result!.AcceptLanguage.Should().Be("en-US");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_MultipleHeaders_AllAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer jwt-token",
|
||||
["X-Request-Id"] = "correlation-id-xyz",
|
||||
["X-Custom-Header"] = "value-123",
|
||||
["Accept-Language"] = "fr-FR"
|
||||
};
|
||||
|
||||
// Act
|
||||
var builder = _fixture.CreateRequest("GET", "/headers");
|
||||
foreach (var header in headers)
|
||||
{
|
||||
builder.WithHeader(header.Key, header.Value);
|
||||
}
|
||||
var response = await builder.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.AllHeaders.Should().ContainKey("Authorization");
|
||||
result.AllHeaders.Should().ContainKey("X-Request-Id");
|
||||
result.AllHeaders.Should().ContainKey("X-Custom-Header");
|
||||
result.AllHeaders.Should().ContainKey("Accept-Language");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromHeader_BearerToken_ParsesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/headers")
|
||||
.WithBearerToken("my-jwt-token-value")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<HeaderTestResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Authorization.Should().Be("Bearer my-jwt-token-value");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromBody - JSON Body Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_SimpleJson_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest("Hello, World!"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Hello, World!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_ComplexObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateUserRequest("John Doe", "john@example.com");
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/users")
|
||||
.WithJsonBody(request)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CreateUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
result.UserId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_AnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new { Message = "Anonymous type test" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Anonymous type test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_NestedObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange - For raw echo we can test nested JSON structure
|
||||
var nested = new
|
||||
{
|
||||
level1 = new
|
||||
{
|
||||
level2 = new
|
||||
{
|
||||
value = "deeply nested"
|
||||
}
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(nested);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(Encoding.UTF8.GetBytes(json), "application/json")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Contain("deeply nested");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromBody_CamelCaseNaming_BindsCorrectly()
|
||||
{
|
||||
// Arrange - Ensure camelCase property naming works
|
||||
var json = JsonSerializer.Serialize(new { message = "camelCase test" }, _jsonOptions);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(Encoding.UTF8.GetBytes(json), "application/json")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Contain("camelCase test");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FromForm - Form Data Binding
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_SimpleFormData_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "testuser")
|
||||
.WithFormField("password", "secret123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("testuser");
|
||||
result!.Password.Should().Be("secret123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_BooleanField_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "user")
|
||||
.WithFormField("password", "pass")
|
||||
.WithFormField("rememberMe", "true")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.RememberMe.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_WithAnonymousObject_BindsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormFields(new { Username = "bulk-user", Password = "bulk-pass", RememberMe = "false" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("bulk-user");
|
||||
result!.Password.Should().Be("bulk-pass");
|
||||
result!.RememberMe.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_UrlEncodedSpecialChars_BindsCorrectly()
|
||||
{
|
||||
// Arrange - Special characters that need URL encoding
|
||||
var password = "p@ss=word&special!";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "test")
|
||||
.WithFormField("password", password)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Password.Should().Be(password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FromForm_ContentType_IsCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/login")
|
||||
.WithFormField("username", "test")
|
||||
.WithFormField("password", "test")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<FormDataResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ContentType.Should().Contain("application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Combined Binding - Multiple Sources
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - PUT /resources/{resourceId} with JSON body
|
||||
var body = new { Name = "Updated Resource", Description = "New description" };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/res-123")
|
||||
.WithJsonBody(body)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("res-123");
|
||||
result!.Name.Should().Be("Updated Resource");
|
||||
result!.Description.Should().Be("New description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_PathQueryAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - PUT /resources/{resourceId}?format=json&verbose=true with body
|
||||
var body = new { Name = "Full Update", Description = "Verbose mode" };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/res-456")
|
||||
.WithQuery("format", "json")
|
||||
.WithQuery("verbose", "true")
|
||||
.WithJsonBody(body)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("res-456");
|
||||
result!.Format.Should().Be("json");
|
||||
result!.Verbose.Should().BeTrue();
|
||||
result!.Name.Should().Be("Full Update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedBinding_HeadersAndBody_BindCorrectly()
|
||||
{
|
||||
// Arrange - POST with headers and JSON body
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithHeader("X-Request-Id", "combo-test-123")
|
||||
.WithJsonBody(new EchoRequest("Combined header and body"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain("Combined header and body");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Methods
|
||||
|
||||
[Fact]
|
||||
public async Task HttpGet_ReturnsData()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/users/get-test-user")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be("get-test-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPost_CreatesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("POST", "/users")
|
||||
.WithJsonBody(new CreateUserRequest("New User", "new@example.com"))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CreateUserResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPut_UpdatesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("PUT", "/resources/update-me")
|
||||
.WithJsonBody(new { Name = "Updated Name", Description = "Updated via PUT" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<CombinedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ResourceId.Should().Be("update-me");
|
||||
result!.Name.Should().Be("Updated Name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_PartialUpdate()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("PATCH", "/items/patch-item-1")
|
||||
.WithJsonBody(new { Name = "Patched Name", Price = 29.99m })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PatchItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ItemId.Should().Be("patch-item-1");
|
||||
result!.Name.Should().Be("Patched Name");
|
||||
result!.Price.Should().Be(29.99m);
|
||||
result!.UpdatedFields.Should().Contain("name");
|
||||
result!.UpdatedFields.Should().Contain("price");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpPatch_PartialUpdate_OnlySpecifiedFields()
|
||||
{
|
||||
// Arrange & Act - Only update name, not price
|
||||
var response = await _fixture.CreateRequest("PATCH", "/items/partial-patch")
|
||||
.WithJsonBody(new { Name = "Only Name Updated" })
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PatchItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.UpdatedFields.Should().Contain("name");
|
||||
result!.UpdatedFields.Should().NotContain("price");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpDelete_RemovesResource()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("DELETE", "/items/delete-me-123")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<DeleteItemResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.ItemId.Should().Be("delete-me-123");
|
||||
result!.Deleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Raw Body Handling
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_PlainText_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var text = "This is plain text content";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithTextBody(text, "text/plain")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Be(text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_Xml_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var xml = "<root><element>value</element></root>";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithXmlBody(xml)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var body = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
body.Should().Be(xml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_Binary_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bytes = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithRawBody(bytes, "application/octet-stream")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
// The raw echo endpoint reads as string, so binary data may be mangled
|
||||
// This test verifies the transport handles binary content
|
||||
response.Payload.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawBody_ResponseHeaders_IncludeContentLength()
|
||||
{
|
||||
// Arrange
|
||||
var text = "Test content for length";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/raw-echo")
|
||||
.WithTextBody(text)
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Headers.Should().ContainKey("X-Echo-Length");
|
||||
response.Headers["X-Echo-Length"].Should().Be(text.Length.ToString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyBody_HandledCorrectly()
|
||||
{
|
||||
// Arrange & Act - GET with no body should work for endpoints with optional params
|
||||
var response = await _fixture.CreateRequest("GET", "/items")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<PagedResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
// Should use default values when no query params provided
|
||||
result!.Offset.Should().Be(0);
|
||||
result.Limit.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyQueryString_UsesDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = await _fixture.CreateRequest("GET", "/search")
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<SearchResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
// Should use default values from the endpoint
|
||||
result!.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRequests_HandleCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var tasks = Enumerable.Range(1, 10)
|
||||
.Select(i => _fixture.CreateRequest("GET", $"/users/concurrent-user-{i}")
|
||||
.SendAsync());
|
||||
|
||||
// Act
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
responses.Should().HaveCount(10);
|
||||
responses.Should().OnlyContain(r => r.StatusCode == 200);
|
||||
|
||||
for (int i = 0; i < responses.Length; i++)
|
||||
{
|
||||
var result = _fixture.DeserializeResponse<GetUserResponse>(responses[i]);
|
||||
result.Should().NotBeNull();
|
||||
result!.UserId.Should().Be($"concurrent-user-{i + 1}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LargePayload_HandledCorrectly()
|
||||
{
|
||||
// Arrange - Create a moderately large message
|
||||
var largeMessage = new string('x', 10000);
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest(largeMessage))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain(largeMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnicodeContent_HandledCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var unicodeMessage = "Hello 世界! Привет мир! 🎉 مرحبا";
|
||||
|
||||
// Act
|
||||
var response = await _fixture.CreateRequest("POST", "/echo")
|
||||
.WithJsonBody(new EchoRequest(unicodeMessage))
|
||||
.SendAsync();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
var result = _fixture.DeserializeResponse<EchoResponse>(response);
|
||||
result.Should().NotBeNull();
|
||||
result!.Echo.Should().Contain(unicodeMessage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user