Skip to content

Commit cc53b6a

Browse files
test: cover local chat and backend-selection wiring
- Narrow IsValidNpgsqlConnectionString exception handling to ArgumentException. - Add LocalChatService unit tests for request construction, non-success/error mapping, invalid payload handling, and previousResponseId conversation reuse. - Add AddConfiguredChatServices selection tests for Azure, local, fallback, and missing configuration, including EmbeddingRetry options binding coverage.
1 parent 0a39b2c commit cc53b6a

3 files changed

Lines changed: 264 additions & 1 deletion

File tree

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ private static bool IsValidNpgsqlConnectionString(string? connectionString)
299299
var builder = new NpgsqlConnectionStringBuilder(connectionString);
300300
return !string.IsNullOrWhiteSpace(builder.Host);
301301
}
302-
catch (Exception)
302+
catch (ArgumentException)
303303
{
304304
return false;
305305
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System.Net;
2+
using System.Text;
3+
using System.Text.Json;
4+
using EssentialCSharp.Chat;
5+
using EssentialCSharp.Chat.Common.Services;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
8+
using Moq;
9+
10+
namespace EssentialCSharp.Chat.Tests;
11+
12+
public class LocalChatServiceTests
13+
{
14+
[Test]
15+
public async Task GetChatCompletion_BuildsExpectedRequest()
16+
{
17+
var requests = new List<HttpRequestMessage>();
18+
var responses = new Queue<HttpResponseMessage>();
19+
responses.Enqueue(CreateJsonResponse("""
20+
{"id":"resp-1","choices":[{"message":{"content":"hello back"}}]}
21+
"""));
22+
23+
var service = CreateService(new RecordingHttpMessageHandler(requests, responses));
24+
25+
var (response, responseId) = await service.GetChatCompletion("hello");
26+
27+
await Assert.That(response).IsEqualTo("hello back");
28+
await Assert.That(responseId).IsEqualTo("resp-1");
29+
await Assert.That(requests.Count).IsEqualTo(1);
30+
31+
var request = requests[0];
32+
await Assert.That(request.RequestUri!.AbsolutePath).IsEqualTo("/v1/chat/completions");
33+
await Assert.That(request.Headers.Authorization?.Scheme).IsEqualTo("Bearer");
34+
await Assert.That(request.Headers.Authorization?.Parameter).IsEqualTo("local-dev-key");
35+
36+
string requestBody = await request.Content!.ReadAsStringAsync();
37+
using var payload = JsonDocument.Parse(requestBody);
38+
await Assert.That(payload.RootElement.GetProperty("model").GetString()).IsEqualTo("qwen2.5-coder:7b");
39+
await Assert.That(payload.RootElement.GetProperty("stream").GetBoolean()).IsFalse();
40+
41+
var messages = payload.RootElement.GetProperty("messages");
42+
await Assert.That(messages.GetArrayLength()).IsEqualTo(2);
43+
await Assert.That(messages[0].GetProperty("role").GetString()).IsEqualTo("system");
44+
await Assert.That(messages[0].GetProperty("content").GetString()).IsEqualTo("system prompt");
45+
await Assert.That(messages[1].GetProperty("role").GetString()).IsEqualTo("user");
46+
await Assert.That(messages[1].GetProperty("content").GetString()).IsEqualTo("hello");
47+
}
48+
49+
[Test]
50+
public async Task GetChatCompletion_WhenBackendReturnsNonSuccess_ThrowsChatBackendUnavailableException()
51+
{
52+
var requests = new List<HttpRequestMessage>();
53+
var responses = new Queue<HttpResponseMessage>();
54+
responses.Enqueue(new HttpResponseMessage(HttpStatusCode.BadGateway)
55+
{
56+
Content = new StringContent("upstream error", Encoding.UTF8, "text/plain")
57+
});
58+
59+
var service = CreateService(new RecordingHttpMessageHandler(requests, responses));
60+
61+
await Assert.ThrowsAsync<ChatBackendUnavailableException>(() => service.GetChatCompletion("hello"));
62+
}
63+
64+
[Test]
65+
public async Task GetChatCompletion_WhenPayloadIsInvalid_ThrowsChatBackendUnavailableException()
66+
{
67+
var requests = new List<HttpRequestMessage>();
68+
var responses = new Queue<HttpResponseMessage>();
69+
responses.Enqueue(CreateJsonResponse("""{"id":"resp-1","choices":[]}"""));
70+
71+
var service = CreateService(new RecordingHttpMessageHandler(requests, responses));
72+
73+
await Assert.ThrowsAsync<ChatBackendUnavailableException>(() => service.GetChatCompletion("hello"));
74+
}
75+
76+
[Test]
77+
public async Task GetChatCompletion_ReusesConversationHistory_WhenPreviousResponseIdProvided()
78+
{
79+
var requests = new List<HttpRequestMessage>();
80+
var responses = new Queue<HttpResponseMessage>();
81+
responses.Enqueue(CreateJsonResponse("""
82+
{"id":"resp-1","choices":[{"message":{"content":"assistant one"}}]}
83+
"""));
84+
responses.Enqueue(CreateJsonResponse("""
85+
{"id":"resp-2","choices":[{"message":{"content":"assistant two"}}]}
86+
"""));
87+
88+
var service = CreateService(new RecordingHttpMessageHandler(requests, responses));
89+
90+
var first = await service.GetChatCompletion("first");
91+
_ = await service.GetChatCompletion("second", previousResponseId: first.responseId);
92+
93+
await Assert.That(requests.Count).IsEqualTo(2);
94+
95+
string firstBody = await requests[0].Content!.ReadAsStringAsync();
96+
using var firstPayload = JsonDocument.Parse(firstBody);
97+
var firstMessages = firstPayload.RootElement.GetProperty("messages");
98+
await Assert.That(firstMessages.GetArrayLength()).IsEqualTo(2);
99+
100+
string secondBody = await requests[1].Content!.ReadAsStringAsync();
101+
using var secondPayload = JsonDocument.Parse(secondBody);
102+
var secondMessages = secondPayload.RootElement.GetProperty("messages");
103+
await Assert.That(secondMessages.GetArrayLength()).IsEqualTo(4);
104+
await Assert.That(secondMessages[1].GetProperty("role").GetString()).IsEqualTo("user");
105+
await Assert.That(secondMessages[1].GetProperty("content").GetString()).IsEqualTo("first");
106+
await Assert.That(secondMessages[2].GetProperty("role").GetString()).IsEqualTo("assistant");
107+
await Assert.That(secondMessages[2].GetProperty("content").GetString()).IsEqualTo("assistant one");
108+
await Assert.That(secondMessages[3].GetProperty("role").GetString()).IsEqualTo("user");
109+
await Assert.That(secondMessages[3].GetProperty("content").GetString()).IsEqualTo("second");
110+
}
111+
112+
private static LocalChatService CreateService(HttpMessageHandler handler)
113+
{
114+
var options = Options.Create(new AIOptions
115+
{
116+
LocalChatModel = "qwen2.5-coder:7b",
117+
SystemPrompt = "system prompt"
118+
});
119+
120+
var client = new HttpClient(handler)
121+
{
122+
BaseAddress = new Uri("http://localhost:11434")
123+
};
124+
125+
var httpClientFactory = new Mock<IHttpClientFactory>();
126+
httpClientFactory
127+
.Setup(f => f.CreateClient("LocalAIChat"))
128+
.Returns(client);
129+
130+
var logger = Mock.Of<ILogger<LocalChatService>>();
131+
return new LocalChatService(options, httpClientFactory.Object, logger);
132+
}
133+
134+
private static HttpResponseMessage CreateJsonResponse(string json) =>
135+
new(HttpStatusCode.OK)
136+
{
137+
Content = new StringContent(json, Encoding.UTF8, "application/json")
138+
};
139+
140+
private sealed class RecordingHttpMessageHandler(
141+
List<HttpRequestMessage> requests,
142+
Queue<HttpResponseMessage> responses) : HttpMessageHandler
143+
{
144+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
145+
{
146+
var snapshot = new HttpRequestMessage(request.Method, request.RequestUri)
147+
{
148+
Content = request.Content is null
149+
? null
150+
: new StringContent(await request.Content.ReadAsStringAsync(cancellationToken), Encoding.UTF8, request.Content.Headers.ContentType?.MediaType)
151+
};
152+
if (request.Headers.Authorization is not null)
153+
{
154+
snapshot.Headers.Authorization = request.Headers.Authorization;
155+
}
156+
157+
requests.Add(snapshot);
158+
return responses.Dequeue();
159+
}
160+
}
161+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using EssentialCSharp.Chat.Common.Extensions;
2+
using EssentialCSharp.Chat.Common.Models;
3+
using EssentialCSharp.Chat.Common.Services;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace EssentialCSharp.Chat.Tests;
9+
10+
public class ServiceCollectionExtensionsTests
11+
{
12+
[Test]
13+
public async Task AddConfiguredChatServices_WithFullAzureConfig_SelectsAzureChatService_AndBindsEmbeddingRetry()
14+
{
15+
var services = CreateServices(new Dictionary<string, string?>
16+
{
17+
["AIOptions:Endpoint"] = "https://example.openai.azure.com",
18+
["AIOptions:ChatDeploymentName"] = "chat-deployment",
19+
["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment",
20+
["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test",
21+
["AIOptions:EmbeddingRetry:MaxRetries"] = "7"
22+
});
23+
24+
var descriptor = GetChatServiceDescriptor(services);
25+
await Assert.That(descriptor.ImplementationFactory).IsNotNull();
26+
await Assert.That(services.Any(d => d.ServiceType == typeof(AIChatService))).IsTrue();
27+
28+
using var provider = services.BuildServiceProvider();
29+
var retry = provider.GetRequiredService<IOptions<EmbeddingRetryOptions>>().Value;
30+
await Assert.That(retry.MaxRetries).IsEqualTo(7);
31+
}
32+
33+
[Test]
34+
public async Task AddConfiguredChatServices_WithInvalidAzureEndpoint_FallsBackToLocal_WhenLocalConfigIsValid()
35+
{
36+
var services = CreateServices(new Dictionary<string, string?>
37+
{
38+
["AIOptions:Endpoint"] = "not-a-valid-uri",
39+
["AIOptions:ChatDeploymentName"] = "chat-deployment",
40+
["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment",
41+
["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test",
42+
["AIOptions:UseLocalAI"] = "true",
43+
["AIOptions:LocalEndpoint"] = "http://localhost:11434",
44+
["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b"
45+
});
46+
47+
var descriptor = GetChatServiceDescriptor(services);
48+
await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService));
49+
}
50+
51+
[Test]
52+
public async Task AddConfiguredChatServices_WithValidLocalConfig_SelectsLocalBackend()
53+
{
54+
var services = CreateServices(new Dictionary<string, string?>
55+
{
56+
["AIOptions:UseLocalAI"] = "true",
57+
["AIOptions:LocalEndpoint"] = "http://localhost:11434",
58+
["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b"
59+
});
60+
61+
var descriptor = GetChatServiceDescriptor(services);
62+
await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService));
63+
}
64+
65+
[Test]
66+
public async Task AddConfiguredChatServices_WithInvalidLocalEndpoint_SelectsUnavailableBackend()
67+
{
68+
var services = CreateServices(new Dictionary<string, string?>
69+
{
70+
["AIOptions:UseLocalAI"] = "true",
71+
["AIOptions:LocalEndpoint"] = "invalid-uri",
72+
["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b"
73+
});
74+
75+
var descriptor = GetChatServiceDescriptor(services);
76+
await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService));
77+
}
78+
79+
[Test]
80+
public async Task AddConfiguredChatServices_WithMissingConfig_SelectsUnavailableBackend()
81+
{
82+
var services = CreateServices(new Dictionary<string, string?>());
83+
84+
var descriptor = GetChatServiceDescriptor(services);
85+
await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService));
86+
}
87+
88+
private static ServiceCollection CreateServices(Dictionary<string, string?> values)
89+
{
90+
var configuration = new ConfigurationBuilder()
91+
.AddInMemoryCollection(values)
92+
.Build();
93+
94+
var services = new ServiceCollection();
95+
services.AddLogging();
96+
services.AddConfiguredChatServices(configuration);
97+
return services;
98+
}
99+
100+
private static ServiceDescriptor GetChatServiceDescriptor(IServiceCollection services) =>
101+
services.Single(d => d.ServiceType == typeof(IChatCompletionService));
102+
}

0 commit comments

Comments
 (0)