Skip to content

Commit 3695e2c

Browse files
committed
test: define embedded ASP.NET hosting contract
1 parent d261dec commit 3695e2c

2 files changed

Lines changed: 239 additions & 2 deletions

File tree

tests/AiRouter.AspNetCore.Tests/AiRouter.AspNetCore.Tests.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
55
<PackageReference Include="xunit" Version="2.9.3" />
66
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
7-
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
7+
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.11" />
88
<ProjectReference Include="../../src/AiRouter.AspNetCore/AiRouter.AspNetCore.csproj" />
9-
<ProjectReference Include="../../src/AiRouter.Server/AiRouter.Server.csproj" />
109
</ItemGroup>
1110
<ItemGroup><Using Include="Xunit" /></ItemGroup>
1211
</Project>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text;
4+
using System.Text.Json;
5+
using AiRouter.Providers;
6+
using AiRouter.Routing;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Hosting;
9+
using Microsoft.AspNetCore.TestHost;
10+
using Microsoft.Extensions.DependencyInjection;
11+
12+
namespace AiRouter.AspNetCore.Tests;
13+
14+
public sealed class EmbeddedHostingTests
15+
{
16+
[Fact]
17+
public async Task Chat_endpoint_uses_the_exact_registered_router_instance()
18+
{
19+
var router = new RecordingRouter();
20+
await using var app = await StartAsync(router);
21+
var client = app.GetTestClient();
22+
23+
var response = await client.PostAsJsonAsync("/v1/chat/completions", new
24+
{
25+
model = "coding",
26+
messages = new[] { new { role = "user", content = "hello" } }
27+
});
28+
29+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
30+
Assert.Equal("primary", response.Headers.GetValues("X-AiRouter-Provider").Single());
31+
Assert.Equal("model-a", response.Headers.GetValues("X-AiRouter-Model").Single());
32+
Assert.Equal(1, router.ChatCalls);
33+
Assert.Equal("coding", router.LastModel);
34+
Assert.Equal("coding", router.LastBody!.Value.GetProperty("model").GetString());
35+
}
36+
37+
[Fact]
38+
public async Task Responses_endpoint_uses_the_same_router()
39+
{
40+
var router = new RecordingRouter();
41+
await using var app = await StartAsync(router);
42+
var response = await app.GetTestClient().PostAsJsonAsync("/v1/responses", new
43+
{
44+
model = "coding",
45+
input = "hello"
46+
});
47+
48+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
49+
Assert.Equal(1, router.ResponsesCalls);
50+
Assert.Equal("coding", router.LastModel);
51+
}
52+
53+
[Fact]
54+
public async Task Streaming_response_is_forwarded_as_sse()
55+
{
56+
var router = new RecordingRouter { StreamNext = true };
57+
await using var app = await StartAsync(router);
58+
var client = app.GetTestClient();
59+
60+
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions")
61+
{
62+
Content = JsonContent.Create(new
63+
{
64+
model = "coding",
65+
stream = true,
66+
messages = new[] { new { role = "user", content = "hello" } }
67+
})
68+
};
69+
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
70+
71+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
72+
Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType);
73+
Assert.Equal("data: {\"ok\":true}\n\n", await response.Content.ReadAsStringAsync());
74+
Assert.True(router.LastStream);
75+
}
76+
77+
[Fact]
78+
public async Task Missing_model_returns_openai_style_validation_error()
79+
{
80+
var router = new RecordingRouter();
81+
await using var app = await StartAsync(router);
82+
var response = await app.GetTestClient().PostAsJsonAsync("/v1/chat/completions", new
83+
{
84+
messages = new[] { new { role = "user", content = "hello" } }
85+
});
86+
87+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
88+
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
89+
Assert.Equal("invalid_request_error", body.RootElement.GetProperty("error").GetProperty("type").GetString());
90+
Assert.Equal(0, router.ChatCalls);
91+
}
92+
93+
[Fact]
94+
public async Task Router_failure_preserves_status_and_openai_error_envelope()
95+
{
96+
var router = new RecordingRouter { FailNext = true };
97+
await using var app = await StartAsync(router);
98+
var response = await app.GetTestClient().PostAsJsonAsync("/v1/chat/completions", new
99+
{
100+
model = "coding",
101+
messages = Array.Empty<object>()
102+
});
103+
104+
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
105+
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
106+
Assert.Equal("provider down", body.RootElement.GetProperty("error").GetProperty("message").GetString());
107+
}
108+
109+
[Fact]
110+
public async Task Models_endpoint_returns_logical_routes_and_direct_provider_models()
111+
{
112+
var manager = new StubProviderManager([
113+
new ProviderDefinition("primary", "Primary", "fake", "https://unused.test", null, Models: ["model-a"], DefaultModel: "model-a")
114+
]);
115+
var routes = new InMemoryRouteStore();
116+
await routes.UpsertAsync(new RouteDefinition("coding", RoutingStrategy.Fallback, [new RouteTarget("primary", "model-a")]));
117+
118+
await using var app = await StartAsync(new RecordingRouter(), manager, routes);
119+
var response = await app.GetTestClient().GetAsync("/v1/models");
120+
using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
121+
var ids = body.RootElement.GetProperty("data").EnumerateArray().Select(x => x.GetProperty("id").GetString()).ToArray();
122+
123+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
124+
Assert.Contains("coding", ids);
125+
Assert.Contains("primary/model-a", ids);
126+
}
127+
128+
[Fact]
129+
public async Task AddAiRouter_respects_a_pre_registered_custom_router()
130+
{
131+
var router = new RecordingRouter();
132+
var builder = WebApplication.CreateBuilder();
133+
builder.WebHost.UseTestServer();
134+
builder.Services.AddSingleton<IAiRouter>(router);
135+
builder.Services.AddAiRouter();
136+
builder.Services.AddAiRouterAspNetCore();
137+
138+
await using var app = builder.Build();
139+
Assert.Same(router, app.Services.GetRequiredService<IAiRouter>());
140+
}
141+
142+
private static async Task<WebApplication> StartAsync(
143+
IAiRouter router,
144+
IProviderManager? providers = null,
145+
IRouteStore? routes = null)
146+
{
147+
var builder = WebApplication.CreateBuilder();
148+
builder.WebHost.UseTestServer();
149+
builder.Services.AddSingleton(router);
150+
builder.Services.AddSingleton(providers ?? new StubProviderManager([]));
151+
builder.Services.AddSingleton(routes ?? new InMemoryRouteStore());
152+
builder.Services.AddAiRouterAspNetCore();
153+
154+
var app = builder.Build();
155+
app.MapAiRouterOpenAiEndpoints();
156+
await app.StartAsync();
157+
return app;
158+
}
159+
160+
private sealed class RecordingRouter : IAiRouter
161+
{
162+
public int ChatCalls { get; private set; }
163+
public int ResponsesCalls { get; private set; }
164+
public string? LastModel { get; private set; }
165+
public JsonElement? LastBody { get; private set; }
166+
public bool LastStream { get; private set; }
167+
public bool StreamNext { get; init; }
168+
public bool FailNext { get; init; }
169+
170+
public Task<RouterResult> ChatAsync(string model, JsonElement body, bool stream = false, CancellationToken ct = default)
171+
{
172+
ChatCalls++;
173+
Capture(model, body, stream);
174+
return Task.FromResult(Result(stream));
175+
}
176+
177+
public Task<RouterResult> ResponsesAsync(string model, JsonElement body, bool stream = false, CancellationToken ct = default)
178+
{
179+
ResponsesCalls++;
180+
Capture(model, body, stream);
181+
return Task.FromResult(Result(stream));
182+
}
183+
184+
private void Capture(string model, JsonElement body, bool stream)
185+
{
186+
LastModel = model;
187+
LastBody = body.Clone();
188+
LastStream = stream;
189+
}
190+
191+
private RouterResult Result(bool stream)
192+
{
193+
if (FailNext)
194+
return new RouterResult
195+
{
196+
Success = false,
197+
StatusCode = 503,
198+
FailureKind = ProviderFailureKind.ProviderFailure,
199+
ErrorMessage = "provider down"
200+
};
201+
202+
if (StreamNext || stream)
203+
return new RouterResult
204+
{
205+
Success = true,
206+
StatusCode = 200,
207+
ProviderId = "primary",
208+
Model = "model-a",
209+
ContentType = "text/event-stream",
210+
Stream = new MemoryStream(Encoding.UTF8.GetBytes("data: {\"ok\":true}\n\n"))
211+
};
212+
213+
return new RouterResult
214+
{
215+
Success = true,
216+
StatusCode = 200,
217+
ProviderId = "primary",
218+
Model = "model-a",
219+
Body = JsonSerializer.SerializeToElement(new { ok = true })
220+
};
221+
}
222+
}
223+
224+
private sealed class StubProviderManager(IReadOnlyList<ProviderDefinition> definitions) : IProviderManager
225+
{
226+
public IReadOnlyList<IAiProvider> Snapshot => [];
227+
public Task InitializeAsync(CancellationToken ct = default) => Task.CompletedTask;
228+
public Task<IReadOnlyList<ProviderDefinition>> ListAsync(CancellationToken ct = default) => Task.FromResult(definitions);
229+
public Task<ProviderDefinition?> GetAsync(string id, CancellationToken ct = default) => Task.FromResult(definitions.FirstOrDefault(x => x.Id == id));
230+
public Task<ProviderDefinition> AddAsync(ProviderDefinition provider, CancellationToken ct = default) => throw new NotSupportedException();
231+
public Task<ProviderDefinition> UpdateAsync(string id, ProviderDefinition provider, CancellationToken ct = default) => throw new NotSupportedException();
232+
public Task DeleteAsync(string id, CancellationToken ct = default) => throw new NotSupportedException();
233+
public Task<ProviderDefinition> SetEnabledAsync(string id, bool enabled, CancellationToken ct = default) => throw new NotSupportedException();
234+
public Task<ProviderConnectivityResult> TestAsync(string id, CancellationToken ct = default) => throw new NotSupportedException();
235+
public Task<IReadOnlyList<string>> ListModelsAsync(string id, CancellationToken ct = default) =>
236+
Task.FromResult<IReadOnlyList<string>>(definitions.FirstOrDefault(x => x.Id == id)?.Models ?? []);
237+
}
238+
}

0 commit comments

Comments
 (0)