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
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..b0373a78 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, WireBodyData.UnauthorizedError);
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/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..6b8c0439 100644
--- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs
@@ -1,41 +1,46 @@
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"));
}
+ [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()
{
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 +48,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 +71,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 +84,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 +95,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 +126,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 +140,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 53d70e26..1555cbe0 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,56 +57,44 @@ 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]
- [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 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,39 @@ 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(
- HttpStatusCode.BadRequest,
- "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}"));
- using var httpClient = new HttpClient(handler);
- using var client = CreateClient(httpClient);
+ using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError);
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/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.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs
new file mode 100644
index 00000000..591828e7
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs
@@ -0,0 +1,40 @@
+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 const string InvalidCursorError = "{\"_tag\":\"InvalidCursorError\",\"message\":\"stale\"}";
+
+ public const string InvalidRequestError = "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}";
+
+ 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}}}";
+ }
+}
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 78%
rename from tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs
rename to tests/Shared/RecordingHttpHandler.cs
index 5e91e7ea..7197354c 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,16 @@ public RecordingHttpHandler(Func respon
public bool IsDisposed { get; private set; }
+ 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)
{
_requests.Add(new RecordedRequest