mirror of https://github.com/abpframework/abp.git
7 changed files with 1287 additions and 23 deletions
@ -0,0 +1,804 @@ |
|||
#nullable enable |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Volo.Abp.Http.ProxyScripting.Generators; |
|||
using Volo.Abp.Json; |
|||
using Volo.Abp.Timing; |
|||
using Xunit; |
|||
using MicrosoftOptions = Microsoft.Extensions.Options.Options; |
|||
|
|||
namespace Volo.Abp.Http.Client.ClientProxying; |
|||
|
|||
public class ClientProxyRequestPayloadBuilder_FormData_Tests |
|||
{ |
|||
private readonly ClientProxyRequestPayloadBuilder _builder; |
|||
private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer(); |
|||
private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0"); |
|||
|
|||
public ClientProxyRequestPayloadBuilder_FormData_Tests() |
|||
{ |
|||
var services = new ServiceCollection(); |
|||
var scopeFactory = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>(); |
|||
var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions()); |
|||
_builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var stream = MakeStream("hello-direct"); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var multipart = content.ShouldBeOfType<MultipartFormDataContent>(); |
|||
var parts = multipart.ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain"); |
|||
(await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Alice", |
|||
File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var multipart = content.ShouldBeOfType<MultipartFormDataContent>(); |
|||
var parts = multipart.ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
await AssertStreamPart(parts[1], "File", "hello-single", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Files", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadFilesDto |
|||
{ |
|||
Label = "batch", |
|||
Files = new[] |
|||
{ |
|||
new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"), |
|||
new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Label", "batch"); |
|||
await AssertStreamPart(parts[1], "Files", "a-content", "text/plain"); |
|||
await AssertStreamPart(parts[2], "Files", "b-content", "text/csv"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Outer", nameOnMethod: "input"), |
|||
FormFileParam(name: "Child.File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestNestedUploadDto |
|||
{ |
|||
Outer = "outerVal", |
|||
Child = new TestNestedChildDto |
|||
{ |
|||
File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Outer", "outerVal"); |
|||
await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormParam(name: "Tag", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
await AssertStringPart(parts[1], "Tag", "T1"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "input", |
|||
NameOnMethod = "input", |
|||
Type = typeof(TestUploadDto).FullName!, |
|||
TypeSimple = "dto", |
|||
BindingSourceId = ParameterBindingSources.Body, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice" }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var stringContent = content.ShouldBeOfType<StringContent>(); |
|||
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); |
|||
var body = await stringContent.ReadAsStringAsync(); |
|||
body.ShouldContain("\"Name\":\"Alice\""); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task No_Form_Or_Body_Params_Should_Return_Null_Content() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "id", |
|||
NameOnMethod = "id", |
|||
Type = "System.Int32", |
|||
TypeSimple = "int", |
|||
BindingSourceId = ParameterBindingSources.Path, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> { ["id"] = 42 }; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
content.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "Alice", File = null }, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
await AssertStringPart(parts[0], "Name", "Alice"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Outer", nameOnMethod: "input"), |
|||
FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestThreeLevelDto |
|||
{ |
|||
Outer = "outerVal", |
|||
Inner = new TestThreeLevelMiddleDto |
|||
{ |
|||
Child = new TestNestedChildDto |
|||
{ |
|||
File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Outer", "outerVal"); |
|||
await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single(); |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.FileNameStar.ShouldBe("中文-文件名.txt"); |
|||
(await part.ReadAsStringAsync()).ShouldBe("hello-utf8"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "input", |
|||
NameOnMethod = "input", |
|||
Type = typeof(TestUploadDto).FullName!, |
|||
TypeSimple = "dto", |
|||
BindingSourceId = ParameterBindingSources.Body, |
|||
}, |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Alice", |
|||
File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var stringContent = content.ShouldBeOfType<StringContent>(); |
|||
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); |
|||
var body = await stringContent.ReadAsStringAsync(); |
|||
body.ShouldContain("\"Name\":\"Alice\""); |
|||
body.ShouldContain("\"File\""); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Main", nameOnMethod: "input"), |
|||
FormFileParam(name: "Extras", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestMixedUploadDto |
|||
{ |
|||
Label = "combo", |
|||
Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"), |
|||
Extras = new[] |
|||
{ |
|||
new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"), |
|||
new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"), |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(4); |
|||
await AssertStringPart(parts[0], "Label", "combo"); |
|||
await AssertStreamPart(parts[1], "Main", "main-body", "text/plain"); |
|||
await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv"); |
|||
await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other() |
|||
{ |
|||
var actionA = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var actionB = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Label", nameOnMethod: "input"), |
|||
FormFileParam(name: "Files", nameOnMethod: "input"), |
|||
}); |
|||
|
|||
var argsA = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") }, |
|||
}; |
|||
var argsB = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestUploadFilesDto |
|||
{ |
|||
Label = "B", |
|||
Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") }, |
|||
}, |
|||
}; |
|||
|
|||
var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
|
|||
await AssertStringPart(contentA[0], "Name", "A"); |
|||
await AssertStreamPart(contentA[1], "File", "aa", "text/plain"); |
|||
await AssertStringPart(contentB[0], "Label", "B"); |
|||
await AssertStreamPart(contentB[1], "Files", "bb", "text/csv"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
FormParam(name: "ChildOnly", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestInheritedUploadDto |
|||
{ |
|||
Name = "inherited", |
|||
File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"), |
|||
ChildOnly = "extra", |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Name", "inherited"); |
|||
await AssertStreamPart(parts[1], "File", "from-base", "text/plain"); |
|||
await AssertStringPart(parts[2], "ChildOnly", "extra"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestRecordUploadDto( |
|||
Name: "Diana", |
|||
File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Diana"); |
|||
await AssertStreamPart(parts[1], "File", "from-record", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "When", nameOnMethod: "input"), |
|||
FormParam(name: "Status", nameOnMethod: "input"), |
|||
FormParam(name: "Quantity", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestPrimitiveUploadDto |
|||
{ |
|||
When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc), |
|||
Status = TestStatus.Active, |
|||
Quantity = 7, |
|||
File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(4); |
|||
(await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56"); |
|||
(await parts[1].ReadAsStringAsync()).ShouldBe("Active"); |
|||
(await parts[2].ReadAsStringAsync()).ShouldBe("7"); |
|||
await AssertStreamPart(parts[3], "File", "primitives", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Quantity", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestPrimitiveUploadDto |
|||
{ |
|||
Quantity = null, |
|||
File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(1); |
|||
await AssertStreamPart(parts[0], "File", "null-qty", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormParam(name: "ExtraField", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto |
|||
{ |
|||
Name = "poly", |
|||
ExtraField = "derived-only", |
|||
File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"), |
|||
}; |
|||
var args = new Dictionary<string, object?> { ["input"] = polymorphic }; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(3); |
|||
await AssertStringPart(parts[0], "Name", "poly"); |
|||
await AssertStringPart(parts[1], "ExtraField", "derived-only"); |
|||
await AssertStreamPart(parts[2], "File", "from-derived", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Payload", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestGenericUploadDto<string> |
|||
{ |
|||
Payload = "closed-string", |
|||
File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Payload", "closed-string"); |
|||
await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormParam(name: "Payload", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["input"] = new TestGenericUploadDto<int> |
|||
{ |
|||
Payload = 42, |
|||
File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Payload", "42"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source() |
|||
{ |
|||
// Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream`
|
|||
// observes an empty body (no internal buffering). Opting in to buffering
|
|||
// later would double-allocate for large uploads.
|
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
FormFileParam(name: "file", nameOnMethod: "file"), |
|||
}); |
|||
var sourceStream = MakeStream("retry-payload"); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"), |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single(); |
|||
sourceStream.Position.ShouldBe(0); |
|||
|
|||
(await part.ReadAsStringAsync()).ShouldBe("retry-payload"); |
|||
|
|||
sourceStream.Position.ShouldBe(sourceStream.Length); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing() |
|||
{ |
|||
var action = BuildAction(parameters: Array.Empty<ParameterApiDescriptionModel>()); |
|||
var args = new Dictionary<string, object?>(); |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
content.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only() |
|||
{ |
|||
var action = BuildAction(parameters: new[] |
|||
{ |
|||
new ParameterApiDescriptionModel |
|||
{ |
|||
Name = "id", |
|||
NameOnMethod = "id", |
|||
Type = "System.Int32", |
|||
TypeSimple = "int", |
|||
BindingSourceId = ParameterBindingSources.Path, |
|||
}, |
|||
FormParam(name: "Name", nameOnMethod: "input"), |
|||
FormFileParam(name: "File", nameOnMethod: "input"), |
|||
}); |
|||
var args = new Dictionary<string, object?> |
|||
{ |
|||
["id"] = 7, |
|||
["input"] = new TestUploadDto |
|||
{ |
|||
Name = "Bob", |
|||
File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"), |
|||
}, |
|||
}; |
|||
|
|||
var content = await InvokeAsync(action, args); |
|||
|
|||
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList(); |
|||
parts.Count.ShouldBe(2); |
|||
await AssertStringPart(parts[0], "Name", "Bob"); |
|||
await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain"); |
|||
} |
|||
|
|||
private Task<HttpContent?> InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary<string, object?> args) |
|||
=> _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion); |
|||
|
|||
private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters) |
|||
=> new() |
|||
{ |
|||
UniqueName = "TestAction", |
|||
Name = "TestAction", |
|||
HttpMethod = "POST", |
|||
Url = "api/test", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = parameters.ToList(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = "System.String", |
|||
TypeSimple = "string", |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(), |
|||
}; |
|||
|
|||
private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod) |
|||
=> new() |
|||
{ |
|||
Name = name, |
|||
NameOnMethod = nameOnMethod, |
|||
Type = "System.String", |
|||
TypeSimple = "string", |
|||
BindingSourceId = ParameterBindingSources.Form, |
|||
}; |
|||
|
|||
private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod) |
|||
=> new() |
|||
{ |
|||
Name = name, |
|||
NameOnMethod = nameOnMethod, |
|||
Type = typeof(IRemoteStreamContent).FullName!, |
|||
TypeSimple = "stream", |
|||
BindingSourceId = ParameterBindingSources.FormFile, |
|||
}; |
|||
|
|||
private static MemoryStream MakeStream(string text) |
|||
{ |
|||
var ms = new MemoryStream(); |
|||
ms.Write(Encoding.UTF8.GetBytes(text)); |
|||
ms.Position = 0; |
|||
return ms; |
|||
} |
|||
|
|||
private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue) |
|||
{ |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.Name!.Trim('"').ShouldBe(expectedName); |
|||
(await part.ReadAsStringAsync()).ShouldBe(expectedValue); |
|||
} |
|||
|
|||
private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType) |
|||
{ |
|||
var disposition = part.Headers.ContentDisposition; |
|||
disposition.ShouldNotBeNull(); |
|||
disposition!.Name!.Trim('"').ShouldBe(expectedName); |
|||
part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType); |
|||
(await part.ReadAsStringAsync()).ShouldBe(expectedBody); |
|||
} |
|||
|
|||
private class TestUploadDto |
|||
{ |
|||
public string? Name { get; set; } |
|||
public string? Tag { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestUploadFilesDto |
|||
{ |
|||
public string? Label { get; set; } |
|||
public IEnumerable<IRemoteStreamContent>? Files { get; set; } |
|||
} |
|||
|
|||
private class TestNestedUploadDto |
|||
{ |
|||
public string? Outer { get; set; } |
|||
public TestNestedChildDto? Child { get; set; } |
|||
} |
|||
|
|||
private class TestNestedChildDto |
|||
{ |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestThreeLevelDto |
|||
{ |
|||
public string? Outer { get; set; } |
|||
public TestThreeLevelMiddleDto? Inner { get; set; } |
|||
} |
|||
|
|||
private class TestThreeLevelMiddleDto |
|||
{ |
|||
public TestNestedChildDto? Child { get; set; } |
|||
} |
|||
|
|||
private class TestMixedUploadDto |
|||
{ |
|||
public string? Label { get; set; } |
|||
public IRemoteStreamContent? Main { get; set; } |
|||
public IEnumerable<IRemoteStreamContent>? Extras { get; set; } |
|||
} |
|||
|
|||
private class TestUploadDtoBase |
|||
{ |
|||
public string? Name { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class TestInheritedUploadDto : TestUploadDtoBase |
|||
{ |
|||
public string? ChildOnly { get; set; } |
|||
} |
|||
|
|||
private record TestRecordUploadDto(string Name, IRemoteStreamContent File); |
|||
|
|||
private class TestPrimitiveUploadDto |
|||
{ |
|||
public DateTime When { get; set; } |
|||
public TestStatus Status { get; set; } |
|||
public int? Quantity { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private enum TestStatus |
|||
{ |
|||
Pending = 0, |
|||
Active = 1, |
|||
Done = 2, |
|||
} |
|||
|
|||
private class TestPolymorphicDerivedDto : TestUploadDtoBase |
|||
{ |
|||
public string? ExtraField { get; set; } |
|||
} |
|||
|
|||
private class TestGenericUploadDto<T> |
|||
{ |
|||
public T? Payload { get; set; } |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class StubJsonSerializer : IJsonSerializer |
|||
{ |
|||
public string Serialize(object obj, bool camelCase = true, bool indented = false) |
|||
=> System.Text.Json.JsonSerializer.Serialize(obj); |
|||
|
|||
public T Deserialize<T>(string jsonString, bool camelCase = true) |
|||
=> System.Text.Json.JsonSerializer.Deserialize<T>(jsonString)!; |
|||
|
|||
public object Deserialize(Type type, string jsonString, bool camelCase = true) |
|||
=> System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!; |
|||
} |
|||
|
|||
private class TestClock : IClock |
|||
{ |
|||
public DateTime Now => DateTime.UtcNow; |
|||
public DateTimeKind Kind => DateTimeKind.Utc; |
|||
public bool SupportsMultipleTimezone => false; |
|||
public DateTime Normalize(DateTime dateTime) => dateTime; |
|||
public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime; |
|||
public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset; |
|||
public DateTime ConvertToUtc(DateTime dateTime) => dateTime; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue