From f72c5e5c506e8dc60113f106da8dff514c7d4f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 16:41:30 +0300 Subject: [PATCH 1/5] test: share one FixtureLoader/RecordingHttpHandler pair across the SDK and Extensions test projects The Extensions copies were a namespace-only byte-copy (FixtureLoader) and a divergent same-named twin (RecordingHttpHandler). Both now compile from a tests/Shared source folder linked into each test project; the unified handler records full RecordedRequest entries and keeps the RespondingJson convenience, and the loader derives its resource prefix from the compiling assembly's name. --- .../OpenCode.Sdk.Extensions.Tests.csproj | 5 ++++ ...penCodeServiceCollectionExtensionsTests.cs | 5 ++-- .../Support/RecordingHttpHandler.cs | 30 ------------------- .../OpenCode.Sdk.Tests.csproj | 5 ++++ .../OpenCodeClientContractTests.cs | 2 +- .../OpenCodeResponseTests.cs | 1 + tests/OpenCode.Sdk.Tests/PipelineTests.cs | 1 + .../Serialization/OpenCodeJsonContextTests.cs | 1 + .../Sessions/SessionClientContractTests.cs | 2 +- .../Sessions/SessionsClientContractTests.cs | 2 +- .../Support/FixtureLoader.cs | 22 -------------- .../Support => Shared}/FixtureLoader.cs | 6 ++-- .../Support => Shared}/RecordedRequest.cs | 2 +- .../RecordingHttpHandler.cs | 9 +++++- 14 files changed, 31 insertions(+), 62 deletions(-) delete mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs rename tests/{OpenCode.Sdk.Extensions.Tests/Support => Shared}/FixtureLoader.cs (74%) rename tests/{OpenCode.Sdk.Tests/Support => Shared}/RecordedRequest.cs (89%) rename tests/{OpenCode.Sdk.Tests/Support => Shared}/RecordingHttpHandler.cs (81%) diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj index e4382d15..a905ff3f 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj @@ -22,6 +22,11 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs index 12a6a889..c5607877 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenCode.Sdk.Extensions.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Extensions.Tests; @@ -23,7 +24,7 @@ public async Task AddOpenCode_Should_Resolve_The_Client_Through_The_Factory_Pipe var response = await provider.GetRequiredService().GetHealthAsync(); await Assert.That(response.Health.Healthy).IsTrue(); - await Assert.That(handler.RequestUris.Single()).IsEqualTo(new Uri("http://localhost:4096/api/health")); + await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); } [Test] @@ -86,7 +87,7 @@ public async Task AddOpenCode_Should_Bind_The_Configuration_Section_Including_Cr _ = await provider.GetRequiredService().GetHealthAsync(); var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; - await Assert.That(handler.AuthorizationHeaders.Single()).IsEqualTo(expected); + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); } [Test] diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs deleted file mode 100644 index b36aafc0..00000000 --- a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Net; - -namespace OpenCode.Sdk.Extensions.Tests.Support; - -/// Records every request URI and Authorization header, answering from the supplied responder. -internal sealed class RecordingHttpHandler(Func responder) : HttpMessageHandler -{ - private readonly Func _responder = - responder ?? throw new ArgumentNullException(nameof(responder)); - - private readonly List _requestUris = []; - private readonly List _authorizationHeaders = []; - - public IReadOnlyList RequestUris => _requestUris; - - public IReadOnlyList AuthorizationHeaders => _authorizationHeaders; - - public static RecordingHttpHandler RespondingJson(string body) => - new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body), - }); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - _requestUris.Add(request.RequestUri!); - _authorizationHeaders.Add(request.Headers.Authorization?.ToString()); - return Task.FromResult(_responder(request)); - } -} diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index 2743a1cc..1c1d8e5a 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -25,6 +25,11 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs index a628acbf..0985f8f8 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs @@ -1,6 +1,6 @@ using System.Net; using OpenCode.Sdk.Models; -using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs index eccf7460..6f3d02ef 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs @@ -1,5 +1,6 @@ using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 7b70f38c..e900a253 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -5,6 +5,7 @@ using OpenCode.Sdk.Internal; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index 836bf4f3..344c9b6c 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization.Metadata; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests.Serialization; diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 8a4be2e0..135196e5 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -1,6 +1,6 @@ using System.Net; using OpenCode.Sdk.Models; -using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index 53d70e26..f69ac496 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -1,6 +1,6 @@ using System.Net; using OpenCode.Sdk.Models; -using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; diff --git a/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs deleted file mode 100644 index d02ea2a4..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using System.Text; - -namespace OpenCode.Sdk.Tests.Support; - -internal sealed class FixtureLoader -{ - private const string ResourcePrefix = "OpenCode.Sdk.Tests.Fixtures."; - private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; - - public string LoadJson(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - var resourceName = string.Concat(ResourcePrefix, name); - var stream = _assembly.GetManifestResourceStream(resourceName) - ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); - - using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); - var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); - return content.TrimEnd('\r', '\n'); - } -} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs b/tests/Shared/FixtureLoader.cs similarity index 74% rename from tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs rename to tests/Shared/FixtureLoader.cs index 04d9bc47..bdf6b206 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs +++ b/tests/Shared/FixtureLoader.cs @@ -1,17 +1,17 @@ using System.Reflection; using System.Text; -namespace OpenCode.Sdk.Extensions.Tests.Support; +namespace OpenCode.Sdk.TestSupport; +/// Loads embedded JSON fixtures from the compiling test assembly's Fixtures folder. internal sealed class FixtureLoader { - private const string ResourcePrefix = "OpenCode.Sdk.Extensions.Tests.Fixtures."; private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; public string LoadJson(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - var resourceName = string.Concat(ResourcePrefix, name); + var resourceName = string.Concat(_assembly.GetName().Name, ".Fixtures.", name); var stream = _assembly.GetManifestResourceStream(resourceName) ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs b/tests/Shared/RecordedRequest.cs similarity index 89% rename from tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs rename to tests/Shared/RecordedRequest.cs index c0a20020..1f9bd271 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs +++ b/tests/Shared/RecordedRequest.cs @@ -1,4 +1,4 @@ -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport; internal sealed record RecordedRequest { diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs b/tests/Shared/RecordingHttpHandler.cs similarity index 81% rename from tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs rename to tests/Shared/RecordingHttpHandler.cs index 5e91e7ea..24a9589e 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs +++ b/tests/Shared/RecordingHttpHandler.cs @@ -1,7 +1,8 @@ using System.Net; -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport; +/// Records every request flowing through the handler, answering from the supplied responder. internal sealed class RecordingHttpHandler : HttpMessageHandler { private readonly Func _responder; @@ -26,6 +27,12 @@ public RecordingHttpHandler(Func respon public bool IsDisposed { get; private set; } + public static RecordingHttpHandler RespondingJson(string body) => + new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body), + }); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _requests.Add(new RecordedRequest From 1f08ae3172747da8208295fb58f35ce4bd3dd67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 16:47:44 +0300 Subject: [PATCH 2/5] test: centralize the contract-test scaffold and wire bodies The Endpoint/CreateClient/JsonResponse trio was copy-pasted verbatim across the three contract-test classes, and the recurring error/envelope literals repeated across methods and files. ContractScenario now owns client-over-recording- transport assembly and WireBodyData carries the canned bodies plus the data-envelope and cursor-page shapes; one-off literals that are themselves the subject under test stay inline. --- .../OpenCodeClientContractTests.cs | 170 +++++------------- .../Sessions/SessionClientContractTests.cs | 93 +++------- .../Sessions/SessionsClientContractTests.cs | 111 ++++-------- .../Support/ContractScenario.cs | 46 +++++ .../Support/WireBodyData.cs | 36 ++++ 5 files changed, 186 insertions(+), 270 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/ContractScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs index 0985f8f8..8910eee5 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs @@ -1,23 +1,18 @@ using System.Net; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; public sealed class OpenCodeClientContractTests { - private static readonly Uri Endpoint = new("http://localhost:4096"); - [Test] public async Task GetHealthAsync_Should_Return_The_Typed_Payload() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"healthy\":true,\"version\":\"0.0.0-test\",\"pid\":42}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.HealthOk); - var response = await client.GetHealthAsync(); + var response = await scenario.Client.GetHealthAsync(); await Assert.That(response.Status).IsEqualTo(200); await Assert.That(response.IsError).IsFalse(); @@ -25,21 +20,17 @@ public async Task GetHealthAsync_Should_Return_The_Typed_Payload() await Assert.That(response.Health.Healthy).IsTrue(); await Assert.That(response.Health.Version).IsEqualTo("0.0.0-test"); await Assert.That(response.Health.Pid).IsEqualTo(42); - await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); - await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Get); + await Assert.That(scenario.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); + await Assert.That(scenario.Requests.Single().Method).IsEqualTo(HttpMethod.Get); } [Test] public async Task GetHealthAsync_Should_Throw_The_Typed_Error_By_Default() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.Unauthorized, - "{\"_tag\":\"UnauthorizedError\",\"message\":\"password required\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); var exception = await Assert - .That(async () => _ = await client.GetHealthAsync()) + .That(async () => _ = await scenario.Client.GetHealthAsync()) .Throws(); await Assert.That(exception!.Status).IsEqualTo(401); @@ -52,38 +43,28 @@ public async Task GetHealthAsync_Should_Throw_The_Typed_Error_By_Default() public async Task GetMessageAsync_Should_Unwrap_The_Data_Envelope() { var payload = new FixtureLoader().LoadJson("Serialization.known-session-message.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); - var response = await client.Sessions.GetSessionClient("ses_1 x").GetMessageAsync("msg_2/y"); + var response = await scenario.Client.Sessions.GetSessionClient("ses_1 x").GetMessageAsync("msg_2/y"); await Assert.That(response.Status).IsEqualTo(200); await Assert.That(response.Message).IsTypeOf(); await Assert.That(((SessionMessageUser)response.Message).Id).IsEqualTo("message-1"); - await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri) .IsEqualTo("http://localhost:4096/api/session/ses_1%20x/message/msg_2%2Fy"); } [Test] public async Task GetMessageAsync_Should_Type_Both_Declared_404_Errors() { - using var sessionHandler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"s\",\"message\":\"gone\"}")); - using var sessionHttpClient = new HttpClient(sessionHandler); - using var sessionClient = CreateClient(sessionHttpClient); - using var messageHandler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"MessageNotFoundError\",\"sessionID\":\"s\",\"messageID\":\"m\",\"message\":\"gone\"}")); - using var messageHttpClient = new HttpClient(messageHandler); - using var messageClient = CreateClient(messageHttpClient); + using var sessionScenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.SessionNotFoundError); + using var messageScenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.MessageNotFoundError); var sessionMiss = await Assert - .That(async () => _ = await sessionClient.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await sessionScenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); var messageMiss = await Assert - .That(async () => _ = await messageClient.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await messageScenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); await Assert.That(sessionMiss!.Error).IsTypeOf(); @@ -93,13 +74,9 @@ public async Task GetMessageAsync_Should_Type_Both_Declared_404_Errors() [Test] public async Task GetMessageAsync_Should_Return_The_Error_Envelope_When_NoThrow() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"s\",\"message\":\"gone\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.SessionNotFoundError); - var response = await client.Sessions.GetSessionClient("s").GetMessageAsync("m", OpenCodeRequestOptions.NoThrow); + var response = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m", OpenCodeRequestOptions.NoThrow); await Assert.That(response.IsError).IsTrue(); await Assert.That(response.Status).IsEqualTo(404); @@ -110,14 +87,10 @@ public async Task GetMessageAsync_Should_Return_The_Error_Envelope_When_NoThrow( [Test] public async Task GetMessageAsync_Should_Downgrade_An_Unknown_Tag_To_The_Carrier() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"BrandNewError\",\"detail\":7}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, "{\"_tag\":\"BrandNewError\",\"detail\":7}"); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); var unknown = (UnknownOpenCodeError)exception!.Error!; @@ -128,14 +101,10 @@ public async Task GetMessageAsync_Should_Downgrade_An_Unknown_Tag_To_The_Carrier [Test] public async Task GetMessageAsync_Should_Downgrade_An_Undeclared_Status_To_The_Carrier() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - (HttpStatusCode)418, - "{\"_tag\":\"UnauthorizedError\",\"message\":\"weird\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding((HttpStatusCode)418, "{\"_tag\":\"UnauthorizedError\",\"message\":\"weird\"}"); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); await Assert.That(exception!.Status).IsEqualTo(418); @@ -146,14 +115,10 @@ public async Task GetMessageAsync_Should_Downgrade_An_Undeclared_Status_To_The_C [Test] public async Task GetMessageAsync_Should_Downgrade_A_Known_Tag_At_The_Wrong_Status() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.BadRequest, - "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"s\",\"message\":\"misplaced\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.SessionNotFoundError); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); await Assert.That(exception!.Status).IsEqualTo(400); @@ -164,113 +129,86 @@ public async Task GetMessageAsync_Should_Downgrade_A_Known_Tag_At_The_Wrong_Stat [Test] public async Task GetHealthAsync_Should_Preserve_The_Raw_Body_For_Malformed_Errors() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.BadRequest, - "not json")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + const string body = "not json"; + using var throwScenario = ContractScenario.Responding(HttpStatusCode.BadRequest, body); var thrown = await Assert - .That(async () => _ = await client.GetHealthAsync()) + .That(async () => _ = await throwScenario.Client.GetHealthAsync()) .Throws(); await Assert.That(thrown!.Error).IsNull(); - await Assert.That(thrown.RawBody).IsEqualTo("not json"); - - using var noThrowHandler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.BadRequest, - "not json")); - using var noThrowHttpClient = new HttpClient(noThrowHandler); - using var noThrowClient = CreateClient(noThrowHttpClient); - var response = await noThrowClient.GetHealthAsync(OpenCodeRequestOptions.NoThrow); + await Assert.That(thrown.RawBody).IsEqualTo(body); + + using var noThrowScenario = ContractScenario.Responding(HttpStatusCode.BadRequest, body); + var response = await noThrowScenario.Client.GetHealthAsync(OpenCodeRequestOptions.NoThrow); await Assert.That(response.Error).IsNull(); - await Assert.That(response.RawBody).IsEqualTo("not json"); + await Assert.That(response.RawBody).IsEqualTo(body); } [Test] public async Task GetHealthAsync_Should_Treat_A_Malformed_Success_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.OK, "not json")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "not json"); _ = await Assert - .That(async () => _ = await client.GetHealthAsync()) + .That(async () => _ = await scenario.Client.GetHealthAsync()) .Throws(); } [Test] public async Task GetHealthAsync_Should_Treat_An_Undeclared_2xx_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.Created, - "{\"healthy\":true,\"version\":\"0.0.0-test\",\"pid\":42}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.Created, WireBodyData.HealthOk); _ = await Assert - .That(async () => _ = await client.GetHealthAsync()) + .That(async () => _ = await scenario.Client.GetHealthAsync()) .Throws(); _ = await Assert - .That(async () => _ = await client.GetHealthAsync(OpenCodeRequestOptions.NoThrow)) + .That(async () => _ = await scenario.Client.GetHealthAsync(OpenCodeRequestOptions.NoThrow)) .Throws(); } [Test] public async Task GetHealthAsync_Should_Preserve_The_Raw_Body_For_An_Empty_Error_Tag() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.BadRequest, - "{\"_tag\":\"\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + const string body = "{\"_tag\":\"\"}"; + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, body); - var response = await client.GetHealthAsync(OpenCodeRequestOptions.NoThrow); + var response = await scenario.Client.GetHealthAsync(OpenCodeRequestOptions.NoThrow); await Assert.That(response.IsError).IsTrue(); await Assert.That(response.Error).IsNull(); - await Assert.That(response.RawBody).IsEqualTo("{\"_tag\":\"\"}"); + await Assert.That(response.RawBody).IsEqualTo(body); } [Test] public async Task GetMessageAsync_Should_Treat_An_Empty_Success_Marker_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":{\"type\":\"\",\"id\":\"m\"}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope("{\"type\":\"\",\"id\":\"m\"}")); _ = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("s").GetMessageAsync("m")) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) .Throws(); } [Test] public async Task GetMessageAsync_Should_Escape_Route_Values_Into_The_Request_Uri() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":{\"id\":\"message-1\",\"time\":{\"created\":1},\"text\":\"hello\",\"type\":\"user\"}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + var payload = new FixtureLoader().LoadJson("Serialization.known-session-message.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); - _ = await client.Sessions.GetSessionClient("a b").GetMessageAsync("c/d"); + _ = await scenario.Client.Sessions.GetSessionClient("a b").GetMessageAsync("c/d"); - await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri) .IsEqualTo("http://localhost:4096/api/session/a%20b/message/c%2Fd"); } [Test] public async Task Payload_Should_Guard_Access_And_Printing_On_The_Error_Path() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.Unauthorized, - "{\"_tag\":\"UnauthorizedError\",\"message\":\"no\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - var response = await client.GetHealthAsync(OpenCodeRequestOptions.NoThrow); + var response = await scenario.Client.GetHealthAsync(OpenCodeRequestOptions.NoThrow); _ = Assert.Throws(() => _ = response.Health); var printed = response.ToString(); @@ -294,7 +232,7 @@ public async Task Client_Should_Fail_Instructively_On_The_Unoverridden_Mock_Seam [Test] public async Task Dispose_Should_Make_The_Bound_Handles_Unusable() { - var client = new OpenCodeClient(new OpenCodeClientOptions { Endpoint = Endpoint }); + var client = new OpenCodeClient(new OpenCodeClientOptions { Endpoint = ContractScenario.Endpoint }); var session = client.Sessions.GetSessionClient("s"); client.Dispose(); @@ -318,19 +256,7 @@ public async Task Constructor_Should_Require_An_Endpoint_For_An_Owned_Client() _ = Assert.Throws(() => _ = new OpenCodeClient(new OpenCodeClientOptions())); } - private static OpenCodeClient CreateClient(HttpClient httpClient) => - new(httpClient, new OpenCodeClientOptions - { - Endpoint = Endpoint, - }); - private sealed class MockableClient : OpenCodeClient { } - - private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => - new(status) - { - Content = new StringContent(body), - }; } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 135196e5..3855b054 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -1,28 +1,25 @@ using System.Net; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; public sealed class SessionClientContractTests { - private static readonly Uri Endpoint = new("http://localhost:4096"); - [Test] public async Task GetSessionAsync_Should_Return_The_Typed_Session() { var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); - var response = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync(); + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").GetSessionAsync(); await Assert.That(response.Status).IsEqualTo(200); await Assert.That(response.Session.Id).IsEqualTo("ses_100"); await Assert.That(response.Session.Location.Directory).IsEqualTo("/repo"); await Assert.That(response.Session.Tokens.Cache.Read).IsEqualTo(1); - await Assert.That(handler.Requests.Single().RequestUri) + await Assert.That(scenario.Requests.Single().RequestUri) .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); } @@ -30,12 +27,10 @@ await Assert.That(handler.Requests.Single().RequestUri) public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malformed_Success() { var payload = new FixtureLoader().LoadJson("Serialization.null-parent-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); _ = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) .Throws(); } @@ -43,26 +38,20 @@ public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malf public async Task GetSessionAsync_Should_Treat_A_Numeric_Enum_Status_As_A_Malformed_Success() { var payload = new FixtureLoader().LoadJson("Serialization.numeric-status-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); _ = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) .Throws(); } [Test] public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.SessionNotFoundError); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) .Throws(); await Assert.That(exception!.Status).IsEqualTo(404); @@ -72,14 +61,10 @@ public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() [Test] public async Task GetSessionAsync_Should_Throw_The_Declared_401_Error() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.Unauthorized, - "{\"_tag\":\"UnauthorizedError\",\"message\":\"password required\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) .Throws(); await Assert.That(exception!.Error).IsTypeOf(); @@ -89,13 +74,9 @@ public async Task GetSessionAsync_Should_Throw_The_Declared_401_Error() public async Task ListMessagesAsync_Should_Return_The_Typed_Page_With_Its_Cursor() { var payload = new FixtureLoader().LoadJson("Serialization.known-session-message.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse( - HttpStatusCode.OK, - $"{{\"data\":[{payload}],\"cursor\":{{\"previous\":\"cur_0\"}}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Page(payload, previous: "cur_0")); - var response = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest { Limit = 2, Order = ListOrder.Ascending, @@ -104,35 +85,27 @@ public async Task ListMessagesAsync_Should_Return_The_Typed_Page_With_Its_Cursor await Assert.That(response.Messages.Single()).IsTypeOf(); await Assert.That(response.Cursor.Previous).IsEqualTo("cur_0"); await Assert.That(response.Cursor.Next).IsNull(); - await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri) .IsEqualTo("http://localhost:4096/api/session/ses_100/message?limit=2&order=asc"); } [Test] public async Task ListMessagesAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":[null],\"cursor\":{}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Page("null")); _ = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) .Throws(); } [Test] public async Task ListMessagesAsync_Should_Throw_The_Declared_500_Error() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.InternalServerError, - "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.InternalServerError, WireBodyData.UnknownError); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) .Throws(); await Assert.That(exception!.Status).IsEqualTo(500); @@ -143,13 +116,9 @@ public async Task ListMessagesAsync_Should_Throw_The_Declared_500_Error() [Test] public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_Spine() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.InternalServerError, - "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.InternalServerError, WireBodyData.UnknownError); - var response = await client.Sessions.GetSessionClient("ses_100") + var response = await scenario.Client.Sessions.GetSessionClient("ses_100") .ListMessagesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); await Assert.That(response.IsError).IsTrue(); @@ -161,28 +130,12 @@ public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_S [Test] public async Task ListMessagesAsync_Should_Throw_The_Declared_404_Error() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.NotFound, - "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.SessionNotFoundError); var exception = await Assert - .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").ListMessagesAsync()) + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_9").ListMessagesAsync()) .Throws(); await Assert.That(exception!.Error).IsTypeOf(); } - - private static OpenCodeClient CreateClient(HttpClient httpClient) => - new(httpClient, new OpenCodeClientOptions - { - Endpoint = Endpoint, - }); - - private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => - new(status) - { - Content = new StringContent(body), - }; } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index f69ac496..449daf8e 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -1,43 +1,34 @@ using System.Net; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; public sealed class SessionsClientContractTests { - private static readonly Uri Endpoint = new("http://localhost:4096"); - [Test] public async Task ListSessionsAsync_Should_Return_The_Typed_Page_With_Its_Cursor() { var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse( - HttpStatusCode.OK, - $"{{\"data\":[{payload}],\"cursor\":{{\"next\":\"cur_2\"}}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Page(payload, next: "cur_2")); - var response = await client.Sessions.ListSessionsAsync(); + var response = await scenario.Client.Sessions.ListSessionsAsync(); await Assert.That(response.Status).IsEqualTo(200); await Assert.That(response.Sessions.Single().Id).IsEqualTo("ses_100"); await Assert.That(response.Sessions.Single().Title).IsEqualTo("Fix the build"); await Assert.That(response.Cursor.Next).IsEqualTo("cur_2"); await Assert.That(response.Cursor.Previous).IsNull(); - await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session")); + await Assert.That(scenario.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session")); } [Test] public async Task ListSessionsAsync_Should_Compose_The_Escaped_Query() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":[],\"cursor\":{}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Page("")); - _ = await client.Sessions.ListSessionsAsync(new SessionListRequest + _ = await scenario.Client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 5, Order = ListOrder.Descending, @@ -46,7 +37,7 @@ public async Task ListSessionsAsync_Should_Compose_The_Escaped_Query() Cursor = "cur_1", }); - await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); } @@ -56,11 +47,9 @@ await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( [Arguments("..")] public async Task GetSessionClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string sessionId) { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(); - var exception = Assert.Throws(() => _ = client.Sessions.GetSessionClient(sessionId)); + var exception = Assert.Throws(() => _ = scenario.Client.Sessions.GetSessionClient(sessionId)); await Assert.That(exception.ParamName).IsEqualTo("sessionId"); } @@ -68,43 +57,33 @@ public async Task GetSessionClient_Should_Refuse_An_Id_The_Route_Would_Refuse(st [Test] public async Task ListSessionsAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":[null],\"cursor\":{}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Page("null")); _ = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync()) .Throws(); } [Test] public async Task ListSessionsAsync_Should_Treat_A_Null_Page_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( - HttpStatusCode.OK, - "{\"data\":null,\"cursor\":{}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "{\"data\":null,\"cursor\":{}}"); _ = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync()) .Throws(); } [Test] public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Sending() { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(); var exception = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 0 })) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 0 })) .Throws(); await Assert.That(exception!.ParamName).IsEqualTo("request"); - await Assert.That(handler.Requests).IsEmpty(); + await Assert.That(scenario.Requests).IsEmpty(); } [Test] @@ -112,12 +91,10 @@ public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Se [Arguments("{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}", typeof(InvalidRequestError))] public async Task ListSessionsAsync_Should_Type_Both_Declared_400_Errors(string body, Type expected) { - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.BadRequest, body)); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, body); var exception = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync()) .Throws(); await Assert.That(exception!.Status).IsEqualTo(400); @@ -127,24 +104,20 @@ public async Task ListSessionsAsync_Should_Type_Both_Declared_400_Errors(string [Test] public async Task ListSessionsAsync_Should_Treat_An_Undeclared_Success_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.Accepted, "{}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.Accepted, "{}"); _ = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync()) .Throws(); } [Test] public async Task ListSessionsAsync_Should_Treat_A_Missing_Envelope_As_A_Protocol_Failure() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.OK, "{}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "{}"); _ = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .That(async () => _ = await scenario.Client.Sessions.ListSessionsAsync()) .Throws(); } @@ -152,59 +125,41 @@ public async Task ListSessionsAsync_Should_Treat_A_Missing_Envelope_As_A_Protoco public async Task CreateSessionAsync_Should_Send_The_Empty_Body_When_Omitted() { var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); - var response = await client.Sessions.CreateSessionAsync(); + var response = await scenario.Client.Sessions.CreateSessionAsync(); await Assert.That(response.Session.Id).IsEqualTo("ses_100"); - await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Post); - await Assert.That(handler.Requests.Single().ContentType).IsEqualTo("application/json"); - await Assert.That(handler.Requests.Single().Body).IsEqualTo("{}"); + await Assert.That(scenario.Requests.Single().Method).IsEqualTo(HttpMethod.Post); + await Assert.That(scenario.Requests.Single().ContentType).IsEqualTo("application/json"); + await Assert.That(scenario.Requests.Single().Body).IsEqualTo("{}"); } [Test] public async Task CreateSessionAsync_Should_Send_The_Typed_Body() { var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); - using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(payload)); - _ = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + _ = await scenario.Client.Sessions.CreateSessionAsync(new SessionCreateRequest { Title = "Fresh session", }); - await Assert.That(handler.Requests.Single().Body).IsEqualTo("{\"title\":\"Fresh session\"}"); + await Assert.That(scenario.Requests.Single().Body).IsEqualTo("{\"title\":\"Fresh session\"}"); } [Test] public async Task CreateSessionAsync_Should_Throw_The_Declared_400_Error() { - using var handler = new RecordingHttpHandler(static _ => JsonResponse( + using var scenario = ContractScenario.Responding( HttpStatusCode.BadRequest, - "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}")); - using var httpClient = new HttpClient(handler); - using var client = CreateClient(httpClient); + "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}"); var exception = await Assert - .That(async () => _ = await client.Sessions.CreateSessionAsync()) + .That(async () => _ = await scenario.Client.Sessions.CreateSessionAsync()) .Throws(); await Assert.That(exception!.Error).IsTypeOf(); } - - private static OpenCodeClient CreateClient(HttpClient httpClient) => - new(httpClient, new OpenCodeClientOptions - { - Endpoint = Endpoint, - }); - - private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => - new(status) - { - Content = new StringContent(body), - }; } diff --git a/tests/OpenCode.Sdk.Tests/Support/ContractScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ContractScenario.cs new file mode 100644 index 00000000..5862c762 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ContractScenario.cs @@ -0,0 +1,46 @@ +using System.Net; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +/// Assembles an OpenCodeClient over a recording transport answering with one canned response. +internal sealed class ContractScenario : IDisposable +{ + public static readonly Uri Endpoint = new("http://localhost:4096"); + + private readonly RecordingHttpHandler _handler; + private readonly HttpClient _httpClient; + + private ContractScenario(RecordingHttpHandler handler) + { + _handler = handler; + _httpClient = new HttpClient(handler); + Client = new OpenCodeClient(_httpClient, new OpenCodeClientOptions + { + Endpoint = Endpoint, + }); + } + + public OpenCodeClient Client { get; } + + public IReadOnlyList Requests => _handler.Requests; + + public static ContractScenario Responding(HttpStatusCode status, string body) + { + ArgumentNullException.ThrowIfNull(body); + + return new(new RecordingHttpHandler(_ => new HttpResponseMessage(status) + { + Content = new StringContent(body), + })); + } + + public static ContractScenario Responding() => new(new RecordingHttpHandler()); + + public void Dispose() + { + Client.Dispose(); + _httpClient.Dispose(); + _handler.Dispose(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs new file mode 100644 index 00000000..a2c1980c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs @@ -0,0 +1,36 @@ +namespace OpenCode.Sdk.Tests.Support; + +/// Canned wire bodies and envelope shapes for the contract tests. +internal static class WireBodyData +{ + public const string HealthOk = "{\"healthy\":true,\"version\":\"0.0.0-test\",\"pid\":42}"; + + public const string UnauthorizedError = "{\"_tag\":\"UnauthorizedError\",\"message\":\"password required\"}"; + + public const string SessionNotFoundError = "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}"; + + public const string MessageNotFoundError = "{\"_tag\":\"MessageNotFoundError\",\"sessionID\":\"ses_9\",\"messageID\":\"msg_1\",\"message\":\"gone\"}"; + + public const string UnknownError = "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}"; + + public static string Envelope(string datum) + { + ArgumentNullException.ThrowIfNull(datum); + + return $"{{\"data\":{datum}}}"; + } + + public static string Page(string items, string? previous = null, string? next = null) + { + ArgumentNullException.ThrowIfNull(items); + + var cursor = (previous, next) switch + { + (null, null) => "{}", + (not null, null) => $"{{\"previous\":\"{previous}\"}}", + (null, not null) => $"{{\"next\":\"{next}\"}}", + _ => $"{{\"previous\":\"{previous}\",\"next\":\"{next}\"}}", + }; + return $"{{\"data\":[{items}],\"cursor\":{cursor}}}"; + } +} From 5e224278ebaec687fb3e70b7f76501f66b97e20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 16:51:05 +0300 Subject: [PATCH 3/5] test: observe the object-envelope null-datum refusal The second review verified {"data":null} on the object envelope refuses as a protocol failure but found no test observing it; the list-envelope twin was the only pinned case. --- .../Sessions/SessionClientContractTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 3855b054..6b8c0439 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -23,6 +23,16 @@ await Assert.That(scenario.Requests.Single().RequestUri) .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); } + [Test] + public async Task GetSessionAsync_Should_Treat_A_Null_Datum_As_A_Protocol_Failure() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope("null")); + + _ = await Assert + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .Throws(); + } + [Test] public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malformed_Success() { From 6b0d916cac0e3a2449a14a741c4000291c2b8456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 16:51:23 +0300 Subject: [PATCH 4/5] docs(roadmap): record the PR #26 merge and the #34 consolidation landing --- docs/ROADMAP.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index da500213..814992b7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -56,9 +56,12 @@ route onto the transport spine, conflicting BaseAddress refuses, handle guards m route guards, carrier payload guards, count-guard identity, the Pagination shadow wall) for 932 green tests, and every surviving finding lives milestone-anchored with an explicit trigger — #31 (M2, DI lifetime shape, decision-first), #32 (M3, EscapeDataString TFM -policy), #33 (M3, carrier hand-construction semantics, rides #23), #34 (M2, contract-test -consolidation), plus the #27 and #23 enrichments. Merge is awaiting the maintainer's word; -further breadth batches follow merge. +policy), #33 (M3, carrier hand-construction semantics, rides #23), plus the #27 and #23 +enrichments. PR #26 is merged. The #34 contract-test consolidation landed as its own +follow-up PR: one shared contract scaffold (`ContractScenario`, `WireBodyData`), a single +`FixtureLoader`/`RecordingHttpHandler` pair compiled from `tests/Shared` into both the SDK +and Extensions test projects, and the previously unobserved object-envelope +`{"data":null}` refusal pinned by test. Further breadth batches follow. ## Milestones From ddccbe13298b5e035f03388cd2f280ef26443fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 17:13:45 +0300 Subject: [PATCH 5/5] test: close the review's consolidation misses The InvalidCursorError/InvalidRequestError bodies join WireBodyData (the 400-pair test rides the constants through its Arguments rows), the undeclared- status test reuses the canned UnauthorizedError body, and RespondingJson guards its body at the factory boundary. --- tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs | 2 +- .../Sessions/SessionsClientContractTests.cs | 8 +++----- tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs | 4 ++++ tests/Shared/RecordingHttpHandler.cs | 8 ++++++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs index 8910eee5..b0373a78 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs @@ -101,7 +101,7 @@ public async Task GetMessageAsync_Should_Downgrade_An_Unknown_Tag_To_The_Carrier [Test] public async Task GetMessageAsync_Should_Downgrade_An_Undeclared_Status_To_The_Carrier() { - using var scenario = ContractScenario.Responding((HttpStatusCode)418, "{\"_tag\":\"UnauthorizedError\",\"message\":\"weird\"}"); + using var scenario = ContractScenario.Responding((HttpStatusCode)418, WireBodyData.UnauthorizedError); var exception = await Assert .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("s").GetMessageAsync("m")) diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index 449daf8e..1555cbe0 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -87,8 +87,8 @@ public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Se } [Test] - [Arguments("{\"_tag\":\"InvalidCursorError\",\"message\":\"stale\"}", typeof(InvalidCursorError))] - [Arguments("{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}", typeof(InvalidRequestError))] + [Arguments(WireBodyData.InvalidCursorError, typeof(InvalidCursorError))] + [Arguments(WireBodyData.InvalidRequestError, typeof(InvalidRequestError))] public async Task ListSessionsAsync_Should_Type_Both_Declared_400_Errors(string body, Type expected) { using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, body); @@ -152,9 +152,7 @@ public async Task CreateSessionAsync_Should_Send_The_Typed_Body() [Test] public async Task CreateSessionAsync_Should_Throw_The_Declared_400_Error() { - using var scenario = ContractScenario.Responding( - HttpStatusCode.BadRequest, - "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}"); + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); var exception = await Assert .That(async () => _ = await scenario.Client.Sessions.CreateSessionAsync()) diff --git a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs index a2c1980c..591828e7 100644 --- a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs +++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs @@ -13,6 +13,10 @@ internal static class WireBodyData public const string UnknownError = "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}"; + public const string InvalidCursorError = "{\"_tag\":\"InvalidCursorError\",\"message\":\"stale\"}"; + + public const string InvalidRequestError = "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}"; + public static string Envelope(string datum) { ArgumentNullException.ThrowIfNull(datum); diff --git a/tests/Shared/RecordingHttpHandler.cs b/tests/Shared/RecordingHttpHandler.cs index 24a9589e..7197354c 100644 --- a/tests/Shared/RecordingHttpHandler.cs +++ b/tests/Shared/RecordingHttpHandler.cs @@ -27,11 +27,15 @@ public RecordingHttpHandler(Func respon public bool IsDisposed { get; private set; } - public static RecordingHttpHandler RespondingJson(string body) => - new(_ => new HttpResponseMessage(HttpStatusCode.OK) + public static RecordingHttpHandler RespondingJson(string body) + { + ArgumentNullException.ThrowIfNull(body); + + return new(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body), }); + } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {