clawdforge/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs

387 lines
14 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Clawdforge;
using Clawdforge.Exceptions;
using Clawdforge.Models;
using Xunit;
namespace Clawdforge.Tests;
/// <summary>
/// Tests use a custom <see cref="HttpMessageHandler"/> mock — zero extra
/// dependencies, more idiomatic for an SDK than spinning up an ASP.NET
/// TestHost.
/// </summary>
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<Ingredient>();
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<ForgeAuthException>(() =>
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<ForgeApiException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }));
Assert.Equal(502, ex.StatusCode);
// Auth exception type must NOT match — only ForgeApiException.
Assert.IsNotType<ForgeAuthException>(ex);
Assert.Contains("timeout", ex.Body);
}
[Fact]
public async Task Run_TransportFailure_RaisesForgeTransportException()
{
var handler = new MockHandler((HttpRequestMessage req, CancellationToken ct) =>
Task.FromException<HttpResponseMessage>(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<ForgeTransportException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }));
Assert.IsType<HttpRequestException>(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<OperationCanceledException>(() =>
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<string>(), 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<ArgumentException>(() =>
client.RunAsync(new RunRequest { Prompt = "" }));
}
[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);
}
// ---- 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;
}
private sealed class MockHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public MockHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
public MockHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
: this((req, ct) => Task.FromResult(handler(req, ct))) { }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}