Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fresh stdio JSON-RPC MCP server wrapping the frozen /api/v1 surface, superseding the closed read-only PR #76. 26 read tools (six families + search/all-items & search/artists discovery) and cautious-write CRUD: collections (incl. idempotent membership adds for #487), smart collections, schedules, playouts, channels (create/update/delete/reset), and a Jellyfin-focused media-source sync/scan slice. Writes gated behind ERSATZTV_ALLOW_WRITES (default false, runtime-enforced). Security baseline carried forward from PR #76/#289: read-only backstop, JSON-RPC DoS guards + bounded stdin reader, per-request CTS over headers+body, response-size cap, arg validation vs InputSchema, reverse-proxy prefix preservation. Machine-key auth (X-Api-Key, CSRF-exempt). If-Match/ETag round-trip for the one replace-all PUT that honors it. Cold-review fixes folded in: - HIGH: reject control chars (CR/LF) in the ifMatch value before it reaches TryAddWithoutValidation — SocketsHttpHandler writes it verbatim, so a crafted value could smuggle headers onto the X-Api-Key request. - Cache the empty-args JsonDocument (no per-call pooled-doc leak). - Accept explicit JSON null for optional fields so a nullable API field (e.g. dailyRebuildTime) can be cleared as documented. Deferred (documented): the ~40-field replace-list writes and redesign workflow tools (#63-#68). Docs: docs/mcp.md, docs/README.md index, docs/decisions.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
486 lines
20 KiB
C#
486 lines
20 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using ErsatzTV.Mcp;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Mcp.Tests;
|
|
|
|
[TestFixture]
|
|
public class ErsatzTvApiClientTests
|
|
{
|
|
private static ToolDefinition GetChannel() => new(
|
|
"ersatztv_get_channel",
|
|
"Get channel",
|
|
HttpMethod.Get,
|
|
"/api/v1/channels/{id}",
|
|
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
|
|
{
|
|
CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
GetChannel(),
|
|
JsonDocument.Parse("""{"id":12}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeFalse();
|
|
result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
|
|
handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/v1/channels/12"));
|
|
handler.ApiKey.ShouldBe("secret");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
|
|
{
|
|
CapturingHandler handler = new("""{"id":1}""");
|
|
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_get_resolution_by_name",
|
|
"Get resolution",
|
|
HttpMethod.Get,
|
|
"/api/v1/ffmpeg/resolution/by-name/{name}",
|
|
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
|
|
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/ffmpeg/resolution/by-name/1920%20x%201080"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
|
|
{
|
|
CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
|
|
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
GetChannel(),
|
|
JsonDocument.Parse("""{"id":404}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeTrue();
|
|
result.Text.ShouldContain("404");
|
|
result.Text.ShouldContain("Resource not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
|
|
{
|
|
CapturingHandler handler = new("{}");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_delete_channel",
|
|
"Delete channel",
|
|
HttpMethod.Delete,
|
|
"/api/v1/channels/{id}",
|
|
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
|
|
JsonDocument.Parse("""{"id":12}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeTrue();
|
|
result.Text.ShouldContain("read-only");
|
|
// The request must never reach the API.
|
|
handler.RequestUri.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
|
|
{
|
|
CapturingHandler handler = new("", HttpStatusCode.NoContent);
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_delete_channel",
|
|
"Delete channel",
|
|
HttpMethod.Delete,
|
|
"/api/v1/channels/{id}",
|
|
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
|
|
JsonDocument.Parse("""{"id":12}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeFalse();
|
|
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/channels/12"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Serialize_Non_Path_Args_As_Json_Body_For_Post()
|
|
{
|
|
CapturingHandler handler = new("""{"id":9,"name":"Kids"}""", HttpStatusCode.Created);
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_create_collection",
|
|
"Create collection",
|
|
HttpMethod.Post,
|
|
"/api/v1/collections",
|
|
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Name", Required: true))),
|
|
JsonDocument.Parse("""{"name":"Kids"}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections"));
|
|
handler.ContentType.ShouldBe("application/json");
|
|
handler.Body.ShouldBe("""{"name":"Kids"}""");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Keep_Path_Args_Out_Of_The_Body_And_Preserve_Array_Values()
|
|
{
|
|
CapturingHandler handler = new("", HttpStatusCode.NoContent);
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_add_collection_items",
|
|
"Add items",
|
|
HttpMethod.Post,
|
|
"/api/v1/collections/{id}/items",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
|
new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer"))),
|
|
JsonDocument.Parse("""{"id":20,"artistIds":[1,2,3]}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections/20/items"));
|
|
// The path id must not leak into the body; array values are preserved verbatim.
|
|
handler.Body.ShouldBe("""{"artistIds":[1,2,3]}""");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Send_IfMatch_Header_And_Keep_It_Out_Of_The_Body()
|
|
{
|
|
CapturingHandler handler = new("[]");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_update_collection_custom_order",
|
|
"Reorder",
|
|
HttpMethod.Put,
|
|
"/api/v1/collections/{id}/custom-order",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
|
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
|
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
|
JsonDocument.Parse("""{"id":20,"mediaItemIds":[3,1,2],"ifMatch":"\"5\""}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.IfMatch.ShouldBe("\"5\"");
|
|
// ifMatch is a header, not a body field; the path id is also excluded.
|
|
handler.Body.ShouldBe("""{"mediaItemIds":[3,1,2]}""");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Pass_Through_IfMatch_Wildcard_Force_Write()
|
|
{
|
|
CapturingHandler handler = new("[]");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_update_collection_custom_order",
|
|
"Reorder",
|
|
HttpMethod.Put,
|
|
"/api/v1/collections/{id}/custom-order",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
|
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
|
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
|
JsonDocument.Parse("""{"id":20,"mediaItemIds":[1],"ifMatch":"*"}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.IfMatch.ShouldBe("*");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Reject_IfMatch_With_Control_Characters()
|
|
{
|
|
CapturingHandler handler = new("[]");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
// A CR/LF in ifMatch would smuggle a second header via TryAddWithoutValidation — must be
|
|
// rejected before the request is sent (SocketsHttpHandler does not strip it).
|
|
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_update_collection_custom_order",
|
|
"Reorder",
|
|
HttpMethod.Put,
|
|
"/api/v1/collections/{id}/custom-order",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
|
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
|
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
|
JsonDocument.Parse("{\"id\":20,\"mediaItemIds\":[1],\"ifMatch\":\"\\\"5\\\"\\r\\nX-Evil: 1\"}").RootElement,
|
|
CancellationToken.None));
|
|
|
|
handler.RequestUri.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Serialize_Explicit_Null_Body_Field()
|
|
{
|
|
CapturingHandler handler = new("{}");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
// An explicit null on an optional field is preserved in the body (clears a nullable API field).
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_update_playout",
|
|
"Update playout",
|
|
HttpMethod.Put,
|
|
"/api/v1/playouts/{id}",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Playout id", Required: true),
|
|
new SchemaProperty("dailyRebuildTime", "string", "Daily rebuild time; null clears", Required: false))),
|
|
JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.Body.ShouldBe("""{"dailyRebuildTime":null}""");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Append_Declared_Query_Parameters_On_Get()
|
|
{
|
|
CapturingHandler handler = new("[]");
|
|
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_search_all_items",
|
|
"Search",
|
|
HttpMethod.Get,
|
|
"/api/v1/search/all-items",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("query", "string", "Query", Required: false),
|
|
new SchemaProperty("pageNum", "integer", "Page", Required: false)),
|
|
new HashSet<string>(StringComparer.Ordinal) { "query", "pageNum" }),
|
|
JsonDocument.Parse("""{"query":"genre:jazz","pageNum":2}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/search/all-items?query=genre%3Ajazz&pageNum=2");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Put_Query_Params_In_Query_Not_Body_For_Post()
|
|
{
|
|
CapturingHandler handler = new("", HttpStatusCode.Accepted);
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
|
|
|
await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_scan_library",
|
|
"Scan",
|
|
HttpMethod.Post,
|
|
"/api/v1/libraries/{id}/scan",
|
|
ToolInputSchemas.Object(
|
|
new SchemaProperty("id", "integer", "Library id", Required: true),
|
|
new SchemaProperty("deep", "boolean", "Deep", Required: false)),
|
|
new HashSet<string>(StringComparer.Ordinal) { "deep" }),
|
|
JsonDocument.Parse("""{"id":3,"deep":true}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/libraries/3/scan?deep=true");
|
|
// deep is a query param; there is no JSON body.
|
|
handler.Body.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Surface_Response_ETag()
|
|
{
|
|
CapturingHandler handler = new("""{"items":[]}""", HttpStatusCode.OK, etag: "\"3\"");
|
|
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_get_collection_items",
|
|
"Items",
|
|
HttpMethod.Get,
|
|
"/api/v1/collections/{id}/items",
|
|
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Collection id", Required: true))),
|
|
JsonDocument.Parse("""{"id":20}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeFalse();
|
|
result.Text.ShouldContain("[etag: \"3\"]");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Truncate_Oversized_Response()
|
|
{
|
|
CapturingHandler handler = new(new string('x', 500));
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
|
JsonDocument.Parse("{}").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.Text.ShouldStartWith(new string('x', 16));
|
|
result.Text.ShouldContain("truncated");
|
|
result.Text.Length.ShouldBeLessThan(500);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
|
|
{
|
|
CapturingHandler handler = new("""{"id":12}""");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));
|
|
|
|
await client.CallToolAsync(
|
|
GetChannel(),
|
|
JsonDocument.Parse("""{"id":12}""").RootElement,
|
|
CancellationToken.None);
|
|
|
|
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/v1/channels/12"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Reject_Unknown_Argument()
|
|
{
|
|
CapturingHandler handler = new("{}");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
|
|
|
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
|
GetChannel(),
|
|
JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
|
|
CancellationToken.None));
|
|
|
|
handler.RequestUri.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
|
|
{
|
|
CapturingHandler handler = new("{}");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
|
|
|
// ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
|
|
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
|
new ToolDefinition(
|
|
"ersatztv_get_resolution_by_name",
|
|
"Get resolution",
|
|
HttpMethod.Get,
|
|
"/api/v1/ffmpeg/resolution/by-name/{name}",
|
|
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
|
|
JsonDocument.Parse("""{"name":".."}""").RootElement,
|
|
CancellationToken.None));
|
|
|
|
handler.RequestUri.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
|
|
{
|
|
CapturingHandler handler = new("""{"ok":true}""");
|
|
// int.MaxValue would overflow `cap + 1` to a negative array length; the client must
|
|
// clamp to the default instead of crashing.
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
|
JsonDocument.Parse("{}").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.IsError.ShouldBeFalse();
|
|
result.Text.ShouldBe("""{"ok":true}""");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
|
|
{
|
|
// "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
|
|
// mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
|
|
CapturingHandler handler = new("ab\U0001F600");
|
|
var client = new ErsatzTvApiClient(
|
|
new HttpClient(handler),
|
|
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));
|
|
|
|
ToolCallResult result = await client.CallToolAsync(
|
|
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
|
JsonDocument.Parse("{}").RootElement,
|
|
CancellationToken.None);
|
|
|
|
result.Text.ShouldStartWith("ab");
|
|
result.Text.ShouldNotContain("�");
|
|
result.Text.ShouldContain("truncated");
|
|
}
|
|
|
|
private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK, string? etag = null)
|
|
: HttpMessageHandler
|
|
{
|
|
public Uri? RequestUri { get; private set; }
|
|
public string? ApiKey { get; private set; }
|
|
public string? Body { get; private set; }
|
|
public string? ContentType { get; private set; }
|
|
public string? IfMatch { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
RequestUri = request.RequestUri;
|
|
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
|
|
? values.Single()
|
|
: null;
|
|
IfMatch = request.Headers.TryGetValues("If-Match", out IEnumerable<string>? ifMatch)
|
|
? ifMatch.Single()
|
|
: null;
|
|
if (request.Content is not null)
|
|
{
|
|
Body = request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
|
|
ContentType = request.Content.Headers.ContentType?.MediaType;
|
|
}
|
|
|
|
var message = new HttpResponseMessage(statusCode)
|
|
{
|
|
Content = new StringContent(response)
|
|
};
|
|
if (etag is not null)
|
|
{
|
|
message.Headers.ETag = new EntityTagHeaderValue(etag);
|
|
}
|
|
|
|
return Task.FromResult(message);
|
|
}
|
|
}
|
|
}
|