w4c-workflows-api/w4c-workflows-api.Tests/HttpRequestNodeExecutorBinaryTests.cs
2026-09-12 01:02:46 +03:00

377 lines
14 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Credentials;
using w4c_workflows.Services.Nodes;
using w4c_workflows.Services.Nodes.Binary;
using w4c_workflows.Services.Nodes.Executors;
using Xunit;
namespace w4c_workflows.Tests;
/// <summary>
/// Binary/file tests for the HTTP Request node: <c>responseFormat: file</c>
/// downloads into the binary store, and <c>multipart</c>/<c>binary</c> bodies
/// upload an input item's binary attachment. The stub handler keeps it
/// network-free.
/// </summary>
public class HttpRequestNodeExecutorBinaryTests
{
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
public HttpRequestMessage? LastRequest { get; private set; }
public byte[]? LastBytes { get; private set; }
public string? LastBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
if (request.Content != null)
{
LastBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken);
LastBody = Encoding.UTF8.GetString(LastBytes);
}
return _responder(request);
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK)
=> new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static HttpResponseMessage Binary(
byte[] payload, string? contentType = null, string? fileName = null)
{
var content = new ByteArrayContent(payload);
if (contentType != null)
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
if (fileName != null)
content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
{
FileNameStar = fileName,
};
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
}
private static HttpRequestNodeExecutor Executor(StubHandler handler, IBinaryStore store)
=> new(
new StubFactory(handler),
new CredentialTypeCatalog(),
EgressTestData.Guard(),
EgressTestData.Quota(),
store);
private static NodeExecutionContext Context(JsonObject parameters, IReadOnlyList<FlowItem>? input = null)
=> new()
{
Blueprint = Catalog.Get("core.httpRequest")!,
Parameters = parameters,
Inputs = new IReadOnlyList<FlowItem>[] { input ?? Array.Empty<FlowItem>() },
Credentials = new Dictionary<string, CredentialData>(),
};
private static async Task<FlowItem> ItemWithBinaryAsync(
IBinaryStore store,
byte[] bytes,
string property = "data",
string? fileName = "upload.bin",
string? mime = "application/octet-stream")
{
var attachment = await store.SaveAsync(new MemoryStream(bytes), fileName, mime);
return new FlowItem
{
Json = new JsonObject(),
Binary = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal) { [property] = attachment },
};
}
private static async Task<byte[]> ReadAllAsync(Stream stream)
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return buffer.ToArray();
}
private static JsonObject FileOptions(string? putOutputInField = null)
{
var options = new JsonObject { ["responseFormat"] = "file" };
if (putOutputInField != null)
options["putOutputInField"] = putOutputInField;
return options;
}
// ------------------------------------------------------------------ download
[Fact]
public async Task File_response_format_stores_the_body_and_emits_a_binary_item()
{
var store = BinaryTestData.Store();
var payload = Encoding.UTF8.GetBytes("%PDF-1.7 fake report");
var handler = new StubHandler(_ => Binary(payload, "application/pdf", "report.pdf"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/files/ignored",
["options"] = FileOptions(),
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
var item = Assert.Single(outcome.Outputs[0]);
Assert.Equal("report.pdf", item.Json["fileName"]!.GetValue<string>());
Assert.Equal("application/pdf", item.Json["mimeType"]!.GetValue<string>());
Assert.Equal(payload.Length, item.Json["sizeBytes"]!.GetValue<long>());
Assert.Equal("pdf", item.Json["fileExtension"]!.GetValue<string>());
var attachment = Assert.Single(item.Binary!).Value;
Assert.Equal("report.pdf", attachment.FileName);
await using var stored = await store.OpenAsync(attachment.AssetId);
Assert.Equal(payload, await ReadAllAsync(stored!));
}
[Fact]
public async Task File_response_falls_back_to_the_url_segment_for_the_name()
{
var store = BinaryTestData.Store();
var payload = Encoding.UTF8.GetBytes("data");
var handler = new StubHandler(_ => Binary(payload, "application/octet-stream"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/files/report.dat",
["options"] = FileOptions(),
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal("report.dat", Assert.Single(outcome.Outputs[0]).Json["fileName"]!.GetValue<string>());
}
[Fact]
public async Task File_response_honours_put_output_in_field()
{
var store = BinaryTestData.Store();
var handler = new StubHandler(_ => Binary(Encoding.UTF8.GetBytes("x"), "text/plain", "x.txt"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = FileOptions("file"),
}), default);
var item = Assert.Single(outcome.Outputs[0]);
Assert.Equal("x.txt", item.Json["file"]!["fileName"]!.GetValue<string>());
Assert.NotNull(item.Binary!["data"]);
}
[Fact]
public async Task File_response_is_not_paginated()
{
var store = BinaryTestData.Store();
var calls = 0;
var handler = new StubHandler(_ =>
{
calls++;
return Binary(Encoding.UTF8.GetBytes("once"), "application/octet-stream", "once.bin");
});
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject
{
["responseFormat"] = "file",
["pagination"] = new JsonObject { ["mode"] = "nextUrl", ["nextUrlPath"] = "next" },
},
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(1, calls);
Assert.Single(outcome.Outputs[0]);
}
// ------------------------------------------------------------------ upload
[Fact]
public async Task Multipart_body_sends_fields_and_the_input_binary()
{
var store = BinaryTestData.Store();
var fileBytes = Encoding.UTF8.GetBytes("FILE-CONTENT");
var input = await ItemWithBinaryAsync(store, fileBytes, "data", "note.txt", "text/plain");
var handler = new StubHandler(_ => Json("""{"ok":true}"""));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/upload",
["sendBody"] = true,
["bodyContentType"] = "multipart",
["binaryField"] = "data",
["body"] = new JsonObject { ["caption"] = "hi" },
}, new[] { input }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.StartsWith("multipart/form-data", handler.LastRequest!.Content!.Headers.ContentType!.ToString());
Assert.Contains("caption", handler.LastBody);
Assert.Contains("hi", handler.LastBody);
Assert.Contains("note.txt", handler.LastBody);
Assert.Contains("FILE-CONTENT", handler.LastBody);
}
[Fact]
public async Task Multipart_with_an_empty_binary_field_sends_fields_only()
{
var store = BinaryTestData.Store();
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/upload",
["sendBody"] = true,
["bodyContentType"] = "multipart",
["binaryField"] = "",
["body"] = new JsonObject { ["caption"] = "hi" },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Contains("caption", handler.LastBody);
Assert.DoesNotContain("filename=", handler.LastBody);
}
[Fact]
public async Task Binary_body_sends_the_raw_attachment_with_its_mime_type()
{
var store = BinaryTestData.Store();
var fileBytes = Encoding.UTF8.GetBytes("raw-bytes");
var input = await ItemWithBinaryAsync(store, fileBytes, "data", "raw.bin", "application/x-custom");
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "PUT",
["url"] = "https://example.com/blob",
["sendBody"] = true,
["bodyContentType"] = "binary",
["binaryField"] = "data",
}, new[] { input }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal("application/x-custom", handler.LastRequest!.Content!.Headers.ContentType!.MediaType);
Assert.Equal(fileBytes, handler.LastBytes);
}
[Fact]
public async Task Binary_body_defaults_to_octet_stream_when_the_attachment_has_no_mime()
{
var store = BinaryTestData.Store();
var input = await ItemWithBinaryAsync(store, [1, 2, 3], "data", null, null);
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/blob",
["sendBody"] = true,
["bodyContentType"] = "binary",
["binaryField"] = "data",
}, new[] { input }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(
"application/octet-stream", handler.LastRequest!.Content!.Headers.ContentType!.MediaType);
}
[Fact]
public async Task Binary_body_without_the_named_field_fails_before_sending()
{
var store = BinaryTestData.Store();
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/blob",
["sendBody"] = true,
["bodyContentType"] = "binary",
["binaryField"] = "data",
}), default);
Assert.False(outcome.Succeeded);
Assert.Equal("invalid_parameter", outcome.Failure!.Code);
Assert.Contains("no binary field 'data'", outcome.Failure.Message);
}
[Fact]
public async Task Multipart_without_the_named_field_fails()
{
var store = BinaryTestData.Store();
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/upload",
["sendBody"] = true,
["bodyContentType"] = "multipart",
["binaryField"] = "data",
["body"] = new JsonObject { ["caption"] = "hi" },
}), default);
Assert.False(outcome.Succeeded);
Assert.Equal("invalid_parameter", outcome.Failure!.Code);
}
[Fact]
public async Task Unknown_binary_asset_reference_fails()
{
var store = BinaryTestData.Store();
var input = new FlowItem
{
Json = new JsonObject(),
Binary = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal)
{
["data"] = new BinaryAttachment("sha256:" + new string('b', 64), "gone.bin", "application/octet-stream", 1),
},
};
var handler = new StubHandler(_ => Json("{}"));
var outcome = await Executor(handler, store).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/blob",
["sendBody"] = true,
["bodyContentType"] = "binary",
["binaryField"] = "data",
}, new[] { input }), default);
Assert.False(outcome.Succeeded);
Assert.Contains("was not found in the binary store", outcome.Failure!.Message);
}
}