using System.Net; using System.Text; using System.Text.Json; using Clawdforge; using Clawdforge.Exceptions; using Clawdforge.Models; using Xunit; namespace Clawdforge.Tests; /// /// Tests use a custom mock — zero extra /// dependencies, more idiomatic for an SDK than spinning up an ASP.NET /// TestHost. /// public class ForgeClientTests { [Fact] public async Task Healthz_ReturnsParsedBody() { var handler = new MockHandler((req, ct) => { Assert.Equal(HttpMethod.Get, req.Method); Assert.Equal("/healthz", req.RequestUri!.AbsolutePath); return JsonResponse(new { ok = true, claude_present = true, claude_version = "1.2.3 (Claude Code)", }); }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var h = await client.HealthzAsync(); Assert.True(h.Ok); Assert.True(h.ClaudePresent); Assert.Equal("1.2.3 (Claude Code)", h.ClaudeVersion); } [Fact] public async Task Run_SerializesSnakeCase_AndDeserializesResult() { string? capturedBody = null; string? capturedAuth = null; var handler = new MockHandler(async (req, ct) => { Assert.Equal(HttpMethod.Post, req.Method); Assert.Equal("/run", req.RequestUri!.AbsolutePath); capturedAuth = req.Headers.Authorization?.ToString(); capturedBody = await req.Content!.ReadAsStringAsync(ct); return JsonResponse(new { ok = true, result = new { hello = "world" }, duration_ms = 4321, stop_reason = "end_turn", }); }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }, http); var res = await client.RunAsync(new RunRequest { Prompt = "Reply with JSON: {\"hello\":\"world\"}", Model = "sonnet", TimeoutSecs = 60, }); Assert.True(res.Ok); Assert.Equal(4321, res.DurationMs); Assert.Equal("end_turn", res.StopReason); Assert.Equal(JsonValueKind.Object, res.Result.ValueKind); Assert.Equal("world", res.Result.GetProperty("hello").GetString()); Assert.Equal("Bearer cf_test", capturedAuth); Assert.NotNull(capturedBody); // Verify wire shape: snake_case + no nulls. using var doc = JsonDocument.Parse(capturedBody!); Assert.True(doc.RootElement.TryGetProperty("prompt", out _)); Assert.True(doc.RootElement.TryGetProperty("model", out _)); Assert.True(doc.RootElement.TryGetProperty("timeout_secs", out _)); Assert.False(doc.RootElement.TryGetProperty("system", out _)); Assert.False(doc.RootElement.TryGetProperty("files", out _)); } [Fact] public async Task Run_PropagatesStringResult_AsJsonElementString() { var handler = new MockHandler((req, ct) => JsonResponse(new { ok = true, result = "plain text reply", duration_ms = 100, stop_reason = "end_turn", })); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var res = await client.RunAsync(new RunRequest { Prompt = "x" }); Assert.Equal(JsonValueKind.String, res.Result.ValueKind); Assert.Equal("plain text reply", res.AsText()); } [Fact] public async Task Run_AsJsonGenericRoundtrips() { var handler = new MockHandler((req, ct) => JsonResponse(new { ok = true, result = new { qty = 2, unit = "cup", food = "rice" }, duration_ms = 1, stop_reason = "end_turn", })); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var res = await client.RunAsync(new RunRequest { Prompt = "x" }); var ing = res.AsJson(); Assert.NotNull(ing); Assert.Equal(2, ing!.Qty); Assert.Equal("cup", ing.Unit); Assert.Equal("rice", ing.Food); } [Fact] public async Task Run_401_RaisesForgeAuthException() { var handler = new MockHandler((req, ct) => JsonResponse(new { detail = "Not authenticated" }, HttpStatusCode.Unauthorized)); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_bad" }, http); var ex = await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "x" })); Assert.Equal(401, ex.StatusCode); Assert.Contains("Not authenticated", ex.Body); } [Fact] public async Task Run_502_RaisesForgeApiException_WithRunFailureBody() { var handler = new MockHandler((req, ct) => JsonResponse(new { ok = false, error = "timeout after 30s", stderr = "...", duration_ms = 30001, stop_reason = "timeout", }, HttpStatusCode.BadGateway)); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var ex = await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "x" })); Assert.Equal(502, ex.StatusCode); // Auth exception type must NOT match — only ForgeApiException. Assert.IsNotType(ex); Assert.Contains("timeout", ex.Body); } [Fact] public async Task Run_TransportFailure_RaisesForgeTransportException() { var handler = new MockHandler((HttpRequestMessage req, CancellationToken ct) => Task.FromException(new HttpRequestException("connection refused"))); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var ex = await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "x" })); Assert.IsType(ex.InnerException); } [Fact] public async Task Run_HonorsCancellation() { var handler = new MockHandler(async (req, ct) => { await Task.Delay(TimeSpan.FromSeconds(10), ct); return JsonResponse(new { ok = true, result = "x", duration_ms = 0, stop_reason = "end_turn" }); }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); await Assert.ThrowsAnyAsync(() => client.RunAsync(new RunRequest { Prompt = "x" }, cts.Token)); } [Fact] public async Task UploadFile_StreamsMultipart_AndReturnsToken() { var seenBoundary = false; var seenTtl = false; var seenFile = false; var fileName = $"forge-upload-{Guid.NewGuid():N}.txt"; var tmp = Path.Combine(Path.GetTempPath(), fileName); var handler = new MockHandler(async (req, ct) => { Assert.Equal(HttpMethod.Post, req.Method); Assert.Equal("/files", req.RequestUri!.AbsolutePath); var contentType = req.Content!.Headers.ContentType!; Assert.Equal("multipart/form-data", contentType.MediaType); Assert.NotNull(contentType.Parameters.SingleOrDefault(p => p.Name == "boundary")); seenBoundary = true; var body = await req.Content.ReadAsStringAsync(ct); // .NET's MultipartFormDataContent emits unquoted name= / filename= params. seenTtl = body.Contains("name=ttl_secs") && body.Contains("3600"); seenFile = body.Contains("name=file") && body.Contains($"filename={fileName}") && body.Contains("hello forge"); return JsonResponse(new { file_token = "ff_abc123", ttl_secs = 3600, size = 11 }); }); await File.WriteAllTextAsync(tmp, "hello forge"); try { using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var ft = await client.UploadFileAsync(tmp, ttlSecs: 3600); Assert.Equal("ff_abc123", ft.Token); Assert.Equal(3600, ft.TtlSecs); Assert.Equal(11, ft.Size); } finally { File.Delete(tmp); } Assert.True(seenBoundary); Assert.True(seenTtl, "ttl_secs field not found in multipart body"); Assert.True(seenFile, "file field/filename not found in multipart body"); } [Fact] public async Task AdminTokens_FullCrudCycle() { var calls = new List<(HttpMethod method, string path, string? body)>(); var handler = new MockHandler(async (req, ct) => { string? body = null; if (req.Content is not null) { body = await req.Content.ReadAsStringAsync(ct); } calls.Add((req.Method, req.RequestUri!.AbsolutePath, body)); return req.Method.Method switch { "POST" => JsonResponse(new { name = "petalparse", token = "cf_secret_xyz", ip_cidrs = new[] { "172.24.0.0/16" }, }), "GET" => JsonResponse(new { tokens = new object[] { new { name = "petalparse", ip_cidrs = new[] { "172.24.0.0/16" }, created_at = 1714000000 }, new { name = "cauldron", ip_cidrs = Array.Empty(), created_at = 1714000100 }, }, }), "DELETE" => JsonResponse(new { ok = true }), _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), }; }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" }, http); var created = await client.CreateTokenAsync(new CreateTokenRequest { Name = "petalparse", IpCidrs = new[] { "172.24.0.0/16" }, }); Assert.Equal("petalparse", created.Name); Assert.Equal("cf_secret_xyz", created.Token); var list = await client.ListTokensAsync(); Assert.Equal(2, list.Count); Assert.Contains(list, t => t.Name == "cauldron"); await client.RevokeTokenAsync("petalparse"); Assert.Equal(3, calls.Count); Assert.Equal("/admin/tokens", calls[0].path); Assert.Equal("/admin/tokens", calls[1].path); Assert.Equal("/admin/tokens/petalparse", calls[2].path); } [Fact] public async Task RunRequest_WithoutPrompt_ThrowsArgumentException() { using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }); await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "" })); } [Fact] public async Task RunRequest_PromptWithOnlyWhitespace_Rejected() { // Audit L4: required-string args use IsNullOrWhiteSpace consistently — // a prompt of " \t\n " is not a meaningful request. using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }); await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = " \t\n " })); } [Fact] public async Task CreateToken_NameWithOnlyWhitespace_Rejected() { // Audit L4: required-string args use IsNullOrWhiteSpace consistently. using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" }); await Assert.ThrowsAsync(() => client.CreateTokenAsync(new CreateTokenRequest { Name = " " })); } [Fact] public void BaseUrl_IsTrimmedAtConstruction() { using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }); Assert.Equal("http://forge.test", client.BaseUrl); } [Fact] public void BaseUrl_Cached_ReusesString() { // Audit nit: BaseUrl getter shouldn't rebuild the string on every // access. Identity check confirms a single cached instance. using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }); var first = client.BaseUrl; var second = client.BaseUrl; Assert.Same(first, second); } [Fact] public async Task JsonOpts_MaxDepth_RejectsDeeplyNested() { // Audit M1: ForgeClient JsonOpts must cap deserialization depth so // the server's `result` field can't stack-walk the runtime via // pathological nesting. We synthesize a 200-deep object and verify // the SDK surfaces a transport exception (JsonException-wrapped), // NOT a stack-overflow / process termination. var deepResult = new string('[', 200) + new string(']', 200); var body = "{\"ok\":true,\"result\":" + deepResult + ",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}"; var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var ex = await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "x" })); Assert.IsType(ex.InnerException); } [Fact] public async Task SummarizeBody_DeeplyNestedHandled() { // Audit M2: SummarizeBody parses the error body to extract a // `detail`/`error`/`message` string. A hostile error body with // pathological nesting must not crash the summarize path — the // JsonDocumentOptions.MaxDepth=32 cap means JsonDocument.Parse // throws JsonException, which SummarizeBody swallows. Verify the // 401 still surfaces as ForgeAuthException with body intact. var deeplyNestedBody = "{\"detail\":" + new string('[', 200) + new string(']', 200) + "}"; var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent(deeplyNestedBody, Encoding.UTF8, "application/json"), }); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var ex = await Assert.ThrowsAsync(() => client.RunAsync(new RunRequest { Prompt = "x" })); Assert.Equal(401, ex.StatusCode); // Raw body is preserved verbatim; only the summary parse is gated. Assert.Contains("detail", ex.Body); } [Fact] public async Task UploadStreamAsync_DisposesCallerStream() { // Audit M3: documented contract is that the caller's stream IS // disposed by the SDK once the request completes (matches // HttpClient/MultipartFormDataContent/StreamContent convention). var handler = new MockHandler((req, ct) => JsonResponse(new { file_token = "ff_x", ttl_secs = 3600, size = 5 })); using var http = new HttpClient(handler); using var client = new ForgeClient( new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, http); var observed = new DisposeObservingStream(new MemoryStream(Encoding.UTF8.GetBytes("hello"))); var ft = await client.UploadStreamAsync(observed, "hello.txt", ttlSecs: 3600); Assert.Equal("ff_x", ft.Token); Assert.True(observed.WasDisposed, "UploadStreamAsync should dispose the caller's stream — see README contract."); } [Fact] public void AsJson_OnUndefinedResult_DefaultReturned() { // Audit L2: RunResult.AsJson() previously threw InvalidOperationException // when Result.ValueKind == Undefined (default JsonElement). Guarded // to return default(T) instead. var rr = new RunResult { Ok = true, DurationMs = 0 }; Assert.Equal(JsonValueKind.Undefined, rr.Result.ValueKind); // Reference type: returns null. Assert.Null(rr.AsJson()); // Value type: returns default(int) = 0. Assert.Equal(0, rr.AsJson()); } // ---- helpers ----------------------------------------------------------- private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK) { var json = JsonSerializer.Serialize(payload); return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json"), }; } private sealed class Ingredient { public int Qty { get; init; } public string Unit { get; init; } = string.Empty; public string Food { get; init; } = string.Empty; } /// /// Wraps an inner stream and flips a flag the first time /// runs. Used by the /// UploadStreamAsync disposal-contract test. /// private sealed class DisposeObservingStream : Stream { private readonly Stream _inner; public bool WasDisposed { get; private set; } public DisposeObservingStream(Stream inner) { _inner = inner; } public override bool CanRead => _inner.CanRead; public override bool CanSeek => _inner.CanSeek; public override bool CanWrite => _inner.CanWrite; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => _inner.Position = value; } public override void Flush() => _inner.Flush(); public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); public override void SetLength(long value) => _inner.SetLength(value); public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); protected override void Dispose(bool disposing) { WasDisposed = true; if (disposing) _inner.Dispose(); base.Dispose(disposing); } } private sealed class MockHandler : HttpMessageHandler { private readonly Func> _handler; public MockHandler(Func> handler) { _handler = handler; } public MockHandler(Func handler) : this((req, ct) => Task.FromResult(handler(req, ct))) { } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { return _handler(request, cancellationToken); } } }