From a622a9c7de216730bdda66cc36139e069f7bad4e Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 10:54:56 -0700 Subject: [PATCH 01/11] Fix local AI chat backend wiring and fallback handling --- .../Extensions/ServiceCollectionExtensions.cs | 76 +++++++ .../Models/AIOptions.cs | 15 ++ .../Services/AIChatService.cs | 3 +- .../ChatBackendUnavailableException.cs | 4 + .../Services/IChatCompletionService.cs | 35 ++++ .../Services/LocalChatService.cs | 191 ++++++++++++++++++ .../Services/UnavailableChatService.cs | 44 ++++ .../ChatAvailabilityTests.cs | 53 +++++ .../WebApplicationFactory.cs | 6 + .../Identity/Pages/Account/Login.cshtml.cs | 26 ++- .../Identity/Pages/Account/Register.cshtml.cs | 12 +- .../Controllers/ChatController.cs | 45 ++++- EssentialCSharp.Web/Program.cs | 7 +- 13 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs create mode 100644 EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs create mode 100644 EssentialCSharp.Chat.Shared/Services/LocalChatService.cs create mode 100644 EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs create mode 100644 EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index ccc3c5b5..f4102951 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ namespace EssentialCSharp.Chat.Common.Extensions; public static class ServiceCollectionExtensions { private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"]; + private const string LocalChatHttpClientName = "LocalAIChat"; /// /// Adds Azure OpenAI and related AI services to the service collection using Managed Identity @@ -85,6 +86,71 @@ public static IServiceCollection AddAzureOpenAIServices( return services; } + /// + /// Registers chat services using configuration-driven backend selection. + /// This method never throws for missing or partial AI configuration; it falls back to + /// so the app can continue running. + /// + public static IServiceCollection AddConfiguredChatServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("AIOptions")); + + var aiOptions = configuration.GetSection("AIOptions").Get() ?? new AIOptions(); + string? postgresConnectionString = configuration.GetConnectionString("PostgresVectorStore"); + + bool hasAzureEndpoint = !string.IsNullOrWhiteSpace(aiOptions.Endpoint); + bool hasAzureChatDeployment = !string.IsNullOrWhiteSpace(aiOptions.ChatDeploymentName); + bool hasAzureVectorDeployment = !string.IsNullOrWhiteSpace(aiOptions.VectorGenerationDeploymentName); + bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment && !string.IsNullOrWhiteSpace(postgresConnectionString); + + string localEndpoint = ResolveLocalEndpoint(aiOptions, configuration); + bool hasLocalConfig = aiOptions.UseLocalAI + && !string.IsNullOrWhiteSpace(localEndpoint) + && !string.IsNullOrWhiteSpace(aiOptions.LocalChatModel); + + if (hasAzureConfig) + { + // Pre-validate endpoint URI to avoid exceptions in AddAzureOpenAIServices for + // non-empty but invalid endpoint values. + if (!Uri.TryCreate(aiOptions.Endpoint, UriKind.Absolute, out var azureUri) + || (azureUri.Scheme != Uri.UriSchemeHttp && azureUri.Scheme != Uri.UriSchemeHttps)) + { + Console.Error.WriteLine("[AI] Azure endpoint is not a valid http/https URI. Falling back to local/unavailable."); + } + else + { + services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!); + services.AddSingleton(provider => provider.GetRequiredService()); + Console.WriteLine("[AI] Selected backend: Azure/Foundry."); + return services; + } + } + + if (hasLocalConfig) + { + if (!Uri.TryCreate(localEndpoint, UriKind.Absolute, out var localEndpointUri) + || (localEndpointUri.Scheme != Uri.UriSchemeHttp && localEndpointUri.Scheme != Uri.UriSchemeHttps)) + { + services.AddSingleton(); + Console.Error.WriteLine("[AI] Local backend selected but LocalEndpoint is invalid. Falling back to unavailable backend."); + return services; + } + + services.AddHttpClient(LocalChatHttpClientName, client => + { + client.BaseAddress = localEndpointUri; + client.Timeout = TimeSpan.FromSeconds(120); + }); + services.AddSingleton(); + Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible)."); + return services; + } + + services.AddSingleton(); + Console.WriteLine("[AI] Selected backend: Unavailable (missing or invalid AI configuration)."); + return services; + } + /// /// Adds Azure OpenAI and related AI services to the service collection using configuration /// @@ -198,4 +264,14 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity( return services; } + private static string ResolveLocalEndpoint(AIOptions options, IConfiguration configuration) + { + if (!string.IsNullOrWhiteSpace(options.LocalEndpoint)) + { + return options.LocalEndpoint!; + } + + return configuration.GetConnectionString("ollama-chat") ?? string.Empty; + } + } diff --git a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs index 87a01a87..d0574fba 100644 --- a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs +++ b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs @@ -2,6 +2,21 @@ public class AIOptions { + /// + /// Enables local AI backend usage when Azure endpoint is not configured. + /// + public bool UseLocalAI { get; set; } + + /// + /// Local OpenAI-compatible endpoint (for example, Ollama). + /// + public string? LocalEndpoint { get; set; } + + /// + /// Local chat model identifier (for example, qwen2.5-coder:7b). + /// + public string? LocalChatModel { get; set; } + /// /// The Azure OpenAI deployment name for text embedding generation. /// diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index a66bbc65..ac97cd0e 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -12,7 +12,7 @@ namespace EssentialCSharp.Chat.Common.Services; /// /// Service for handling AI chat completions using the OpenAI Responses API /// -public partial class AIChatService +public partial class AIChatService : IChatCompletionService { private readonly AIOptions _Options; private readonly AzureOpenAIClient _AzureClient; @@ -22,6 +22,7 @@ public partial class AIChatService private readonly AISearchService _SearchService; private readonly ILogger _Logger; private readonly FrozenSet _AllowedMcpTools; + public bool IsAvailable => true; public AIChatService(IOptions options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger logger) { diff --git a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs new file mode 100644 index 00000000..61346060 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs @@ -0,0 +1,4 @@ +namespace EssentialCSharp.Chat.Common.Services; + +public class ChatBackendUnavailableException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs b/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs new file mode 100644 index 00000000..67daf98c --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public interface IChatCompletionService +{ + bool IsAvailable { get; } + + Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default); + + IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default); +} diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs new file mode 100644 index 00000000..e186c096 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -0,0 +1,191 @@ +using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public partial class LocalChatService : IChatCompletionService +{ + private const int MaxConversationMessages = 20; + private readonly AIOptions _Options; + private readonly IHttpClientFactory _HttpClientFactory; + private readonly ILogger _Logger; + private readonly ConcurrentDictionary> _ConversationHistory = new(); + + public bool IsAvailable => true; + + public LocalChatService(IOptions options, IHttpClientFactory httpClientFactory, ILogger logger) + { + _Options = options.Value; + _HttpClientFactory = httpClientFactory; + _Logger = logger; + } + + public async Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default) + { + var (client, history, jsonPayload) = PrepareRequest(prompt, systemPrompt, previousResponseId); + + HttpResponseMessage response; + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "local-dev-key"); + + try + { + response = await client.SendAsync(request, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new ChatBackendUnavailableException("Local AI backend is unavailable.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new ChatBackendUnavailableException("Local AI backend timed out.", ex); + } + + using (response) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + LogLocalRequestFailed(_Logger, (int)response.StatusCode, body); + throw new ChatBackendUnavailableException("Local AI backend returned a non-success status."); + } + + try + { + var (text, responseId) = ParseResponse(body); + history.Add(new LocalChatMessage("user", prompt)); + history.Add(new LocalChatMessage("assistant", text)); + _ConversationHistory[responseId] = history.TakeLast(MaxConversationMessages).ToList(); + return (text, responseId); + } + catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) + { + throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", ex); + } + } + } + + public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (response, responseId) = await GetChatCompletion( + prompt, + systemPrompt, + previousResponseId, + mcpClient, + tools, + reasoningEffortLevel, + enableContextualSearch, + endUserId, + cancellationToken); + + if (!string.IsNullOrEmpty(response)) + { + yield return (response, responseId: null); + } + + yield return (string.Empty, responseId); + } + + private (HttpClient Client, List History, string JsonPayload) PrepareRequest( + string prompt, + string? systemPrompt, + string? previousResponseId = null) + { + var client = _HttpClientFactory.CreateClient("LocalAIChat"); + var effectiveSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? _Options.SystemPrompt : systemPrompt; + var history = ResolveHistory(previousResponseId); + + var messages = new List { new { role = "system", content = effectiveSystemPrompt } }; + messages.AddRange(history.Select(m => new { role = m.Role, content = m.Content })); + messages.Add(new { role = "user", content = prompt }); + + var payload = new + { + model = _Options.LocalChatModel, + messages, + stream = false + }; + + return (client, history, JsonSerializer.Serialize(payload)); + } + + private List ResolveHistory(string? previousResponseId) + { + if (string.IsNullOrWhiteSpace(previousResponseId)) + return []; + + return _ConversationHistory.TryGetValue(previousResponseId, out var history) + ? [.. history] + : []; + } + + private static (string Text, string ResponseId) ParseResponse(string body) + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + string responseId = root.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() ?? Guid.NewGuid().ToString("N") + : Guid.NewGuid().ToString("N"); + + if (root.TryGetProperty("choices", out var choices) + && choices.ValueKind == JsonValueKind.Array + && choices.GetArrayLength() > 0) + { + var choice = choices[0]; + if (choice.TryGetProperty("message", out var message) + && message.TryGetProperty("content", out var content) + && content.ValueKind == JsonValueKind.String) + { + return (content.GetString() ?? string.Empty, responseId); + } + } + + if (root.TryGetProperty("message", out var ollamaMessage) + && ollamaMessage.TryGetProperty("content", out var ollamaContent) + && ollamaContent.ValueKind == JsonValueKind.String) + { + return (ollamaContent.GetString() ?? string.Empty, responseId); + } + + throw new InvalidOperationException("Local AI response did not contain any content."); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Local chat request failed with status {StatusCode}. Body: {Body}")] + private static partial void LogLocalRequestFailed(ILogger logger, int statusCode, string body); + + private sealed record LocalChatMessage(string Role, string Content); +} diff --git a/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs new file mode 100644 index 00000000..79981326 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs @@ -0,0 +1,44 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public class UnavailableChatService : IChatCompletionService +{ + public bool IsAvailable => false; + + public Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult<(string response, string responseId)>( + ("Chat service is unavailable in this environment.", Guid.NewGuid().ToString("N"))); + } + + public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return ("Chat service is unavailable in this environment.", responseId: null); + yield return (string.Empty, Guid.NewGuid().ToString("N")); + await Task.CompletedTask; + } +} diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs new file mode 100644 index 00000000..23c4589d --- /dev/null +++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace EssentialCSharp.Web.Tests; + +[NotInParallel("ChatAvailabilityTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class ChatAvailabilityTests(WebApplicationFactory factory) +{ + [Test] + public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() + { + HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + string userId = await McpTestHelper.CreateUserAsync(factory, "chat-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, userId); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/message") + { + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false }) + }; + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.ServiceUnavailable); + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable"); + } + + [Test] + public async Task ChatStream_WhenBackendUnavailable_Returns503WithContract() + { + HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + string userId = await McpTestHelper.CreateUserAsync(factory, "chat-stream-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, userId); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream") + { + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false }) + }; + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.ServiceUnavailable); + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable"); + } +} diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index cf7a978e..9d53ccb2 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using EssentialCSharp.Chat.Common.Services; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using TUnit.AspNetCore; @@ -98,6 +99,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton( _ => TestListingSourceCodeServiceHelper.CreateService()); + + // Override IChatCompletionService with UnavailableChatService so tests are not + // affected by developer AI configuration or environment variables. + services.RemoveAll(); + services.AddSingleton(); }); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 998ee7cb..4e32488a 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -4,6 +4,7 @@ using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -11,7 +12,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel +public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor, IWebHostEnvironment environment) : PageModel { private InputModel? _Input; [BindProperty] @@ -68,7 +69,14 @@ public async Task OnPostAsync(string? returnUrl = null) returnUrl ??= Url.Content("~/"); string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + + // Development-only bypass for E2E testing: allow test tokens without verification. + bool isTestToken = environment.IsDevelopment() && captchaToken == "10000000-aaaa-bbbb-cccc-000000000001"; + + HCaptchaResult? captchaResult = isTestToken + ? new HCaptchaResult { Success = true } + : await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (captchaResult?.Success != true) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); @@ -92,7 +100,19 @@ public async Task OnPostAsync(string? returnUrl = null) } if (foundUser is not null) { - result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); + // For test tokens, bypass email confirmation requirement + if (isTestToken && !foundUser.EmailConfirmed) + { + // Temporarily set email as confirmed for this sign-in when using test token + var tempConfirmed = foundUser.EmailConfirmed; + foundUser.EmailConfirmed = true; + result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); + foundUser.EmailConfirmed = tempConfirmed; + } + else + { + result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); + } // Call the referral service to get the referral ID and set it onto the user claim _ = await referralService.EnsureReferralIdAsync(foundUser); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index a29d2254..b7bf080d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -5,6 +5,7 @@ using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; @@ -22,7 +23,8 @@ public partial class RegisterModel( IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor, - IUserEmailStore emailStore) : PageModel + IUserEmailStore emailStore, + IWebHostEnvironment environment) : PageModel { public string CaptchaSiteKey { get; } = optionsAccessor.Value.SiteKey ?? string.Empty; @@ -95,7 +97,13 @@ public async Task OnPostAsync(string? returnUrl = null) return Page(); } - HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + // Development-only bypass for E2E testing: allow test tokens without verification. + bool isTestToken = environment.IsDevelopment() && hCaptcha_response == "10000000-aaaa-bbbb-cccc-000000000001"; + + HCaptchaResult? response = isTestToken + ? new HCaptchaResult { Success = true } + : await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (response is null) { ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 08f0d29a..02f8539b 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -18,17 +18,17 @@ namespace EssentialCSharp.Web.Controllers; [IgnoreAntiforgeryToken] public partial class ChatController : ControllerBase { - private readonly AIChatService _AIChatService; + private readonly IChatCompletionService _ChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; private readonly ICaptchaService _CaptchaService; private readonly CaptchaOptions _CaptchaOptions; private readonly ILogger _Logger; - public ChatController(ILogger logger, AIChatService aiChatService, + public ChatController(ILogger logger, IChatCompletionService chatService, ResponseIdValidationService responseIdValidationService, ICaptchaService captchaService, IOptions captchaOptions) { - _AIChatService = aiChatService; + _ChatService = chatService; _ResponseIdValidationService = responseIdValidationService; _CaptchaService = captchaService; _CaptchaOptions = captchaOptions.Value; @@ -87,9 +87,12 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque if (!_ResponseIdValidationService.ValidateResponseId(userId, previousResponseId)) return BadRequest(new { error = "Invalid conversation context." }); + if (!_ChatService.IsAvailable) + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Chat service unavailable", errorCode = "chat_unavailable" }); + try { - var (response, responseId) = await _AIChatService.GetChatCompletion( + var (response, responseId) = await _ChatService.GetChatCompletion( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -109,6 +112,10 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque { return BadRequest(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" }); } + catch (ChatBackendUnavailableException) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Chat service unavailable", errorCode = "chat_unavailable" }); + } } [HttpPost("stream")] @@ -155,13 +162,21 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } + if (!_ChatService.IsAvailable) + { + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + Response.ContentType = "application/json"; + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, CancellationToken.None); + return; + } + Response.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-cache"; Response.Headers.Connection = "keep-alive"; try { - await foreach (var (text, responseId) in _AIChatService.GetChatCompletionStream( + await foreach (var (text, responseId) in _ChatService.GetChatCompletionStream( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -247,6 +262,26 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } } + catch (ChatBackendUnavailableException ex) when (!Response.HasStarted) + { + LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + Response.ContentType = "application/json"; + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, CancellationToken.None); + } + catch (ChatBackendUnavailableException ex) + { + LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); + try + { + await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Chat service unavailable\",\"errorCode\":\"chat_unavailable\"}\n\n", CancellationToken.None); + await Response.Body.FlushAsync(CancellationToken.None); + } + catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException) + { + // Best-effort write to an already-streaming response. + } + } catch (Exception ex) when (!Response.HasStarted) { LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index f5c980ad..dc7f0185 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -274,11 +274,8 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddScoped(); - // Add AI Chat services - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddAzureOpenAIServices(configuration); - } + // Add AI Chat services using configuration-driven backend selection. + builder.Services.AddConfiguredChatServices(configuration); // MCP server — always enabled, authenticated via opaque DB-backed tokens. builder.Services.AddScoped(); From 4c076d4536903d3706b3f52debf5b277142abfec Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 11:44:39 -0700 Subject: [PATCH 02/11] fix: replace unbounded ConcurrentDictionary with MemoryCache (sliding 30min TTL, 500 entry limit) - Add private MemoryCache with SizeLimit=500 and 30-minute sliding expiration - Implement IDisposable with GC.SuppressFinalize for proper DI singleton cleanup - Update Set call to use MemoryCacheEntryOptions - Update TryGetValue to use generic overload with null guard --- .../Services/LocalChatService.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs index e186c096..81578bee 100644 --- a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; using System.Linq; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Client; @@ -10,13 +10,19 @@ namespace EssentialCSharp.Chat.Common.Services; -public partial class LocalChatService : IChatCompletionService +public partial class LocalChatService : IChatCompletionService, IDisposable { private const int MaxConversationMessages = 20; + private const int MaxConversationEntries = 500; + private static readonly TimeSpan _HistoryTtl = TimeSpan.FromMinutes(30); + private readonly AIOptions _Options; private readonly IHttpClientFactory _HttpClientFactory; private readonly ILogger _Logger; - private readonly ConcurrentDictionary> _ConversationHistory = new(); + private readonly MemoryCache _ConversationHistory = new(new MemoryCacheOptions { SizeLimit = MaxConversationEntries }); + private static readonly MemoryCacheEntryOptions _HistoryEntryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(_HistoryTtl) + .SetSize(1); public bool IsAvailable => true; @@ -27,6 +33,12 @@ public LocalChatService(IOptions options, IHttpClientFactory httpClie _Logger = logger; } + public void Dispose() + { + _ConversationHistory.Dispose(); + GC.SuppressFinalize(this); + } + public async Task<(string response, string responseId)> GetChatCompletion( string prompt, string? systemPrompt = null, @@ -77,7 +89,7 @@ public LocalChatService(IOptions options, IHttpClientFactory httpClie var (text, responseId) = ParseResponse(body); history.Add(new LocalChatMessage("user", prompt)); history.Add(new LocalChatMessage("assistant", text)); - _ConversationHistory[responseId] = history.TakeLast(MaxConversationMessages).ToList(); + _ConversationHistory.Set(responseId, history.TakeLast(MaxConversationMessages).ToList(), _HistoryEntryOptions); return (text, responseId); } catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) @@ -147,7 +159,7 @@ private List ResolveHistory(string? previousResponseId) if (string.IsNullOrWhiteSpace(previousResponseId)) return []; - return _ConversationHistory.TryGetValue(previousResponseId, out var history) + return _ConversationHistory.TryGetValue(previousResponseId, out List? history) && history is not null ? [.. history] : []; } From 5910f7b03a8b5a7c24706102398bb7553dbbbd2d Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:42:25 -0700 Subject: [PATCH 03/11] fix: remove fragile auth test bypasses Remove the hard-coded development hCaptcha token bypass and temporary email-confirmation mutation from Identity login/register page handlers. Local testing should use the configured hCaptcha test keys or test-host service overrides instead of changing production auth flow code. --- .../Identity/Pages/Account/Login.cshtml.cs | 26 +++---------------- .../Identity/Pages/Account/Register.cshtml.cs | 11 ++------ 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 4e32488a..f302f7df 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -4,7 +4,6 @@ using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -12,7 +11,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor, IWebHostEnvironment environment) : PageModel +public partial class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel { private InputModel? _Input; [BindProperty] @@ -69,13 +68,8 @@ public async Task OnPostAsync(string? returnUrl = null) returnUrl ??= Url.Content("~/"); string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; - - // Development-only bypass for E2E testing: allow test tokens without verification. - bool isTestToken = environment.IsDevelopment() && captchaToken == "10000000-aaaa-bbbb-cccc-000000000001"; - - HCaptchaResult? captchaResult = isTestToken - ? new HCaptchaResult { Success = true } - : await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + + HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); if (captchaResult?.Success != true) { @@ -100,19 +94,7 @@ public async Task OnPostAsync(string? returnUrl = null) } if (foundUser is not null) { - // For test tokens, bypass email confirmation requirement - if (isTestToken && !foundUser.EmailConfirmed) - { - // Temporarily set email as confirmed for this sign-in when using test token - var tempConfirmed = foundUser.EmailConfirmed; - foundUser.EmailConfirmed = true; - result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); - foundUser.EmailConfirmed = tempConfirmed; - } - else - { - result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); - } + result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); // Call the referral service to get the referral ID and set it onto the user claim _ = await referralService.EnsureReferralIdAsync(foundUser); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index b7bf080d..f62e53ae 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -5,7 +5,6 @@ using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; @@ -23,8 +22,7 @@ public partial class RegisterModel( IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor, - IUserEmailStore emailStore, - IWebHostEnvironment environment) : PageModel + IUserEmailStore emailStore) : PageModel { public string CaptchaSiteKey { get; } = optionsAccessor.Value.SiteKey ?? string.Empty; @@ -97,12 +95,7 @@ public async Task OnPostAsync(string? returnUrl = null) return Page(); } - // Development-only bypass for E2E testing: allow test tokens without verification. - bool isTestToken = environment.IsDevelopment() && hCaptcha_response == "10000000-aaaa-bbbb-cccc-000000000001"; - - HCaptchaResult? response = isTestToken - ? new HCaptchaResult { Success = true } - : await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); if (response is null) { From 658ac5201a31dde37356bee58a656ae5253b64db Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:55:28 -0700 Subject: [PATCH 04/11] fix: validate Postgres connection string, bind EmbeddingRetry from config, remove resilience handler from local AI client - IsValidNpgsqlConnectionString() helper validates that a connection string has a non-empty Host instead of just checking for non-whitespace text, preventing the 'your-postgres-connection-string-here' placeholder from passing validation. - AddOptions().Bind() wired in AddConfiguredChatServices so retry config from appsettings is not silently ignored when using the AIOptions overload of AddAzureOpenAIServices. - .RemoveAllResilienceHandlers() added to LocalAIChat HttpClient to prevent the global ConfigureHttpClientDefaults resilience handler (30s attempt / 90s total timeouts) from cutting off long LLM completions. EXTEXP0001 suppressed in the project file. --- .../EssentialCSharp.Chat.Common.csproj | 4 +++ .../Extensions/ServiceCollectionExtensions.cs | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 20ffba5d..8c8728f6 100644 --- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj +++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj @@ -2,6 +2,9 @@ net10.0 + + $(NoWarn);EXTEXP0001 @@ -9,6 +12,7 @@ + diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index f4102951..05b70a11 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; using Microsoft.SemanticKernel; using Npgsql; @@ -101,7 +102,8 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti bool hasAzureEndpoint = !string.IsNullOrWhiteSpace(aiOptions.Endpoint); bool hasAzureChatDeployment = !string.IsNullOrWhiteSpace(aiOptions.ChatDeploymentName); bool hasAzureVectorDeployment = !string.IsNullOrWhiteSpace(aiOptions.VectorGenerationDeploymentName); - bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment && !string.IsNullOrWhiteSpace(postgresConnectionString); + bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment + && IsValidNpgsqlConnectionString(postgresConnectionString); string localEndpoint = ResolveLocalEndpoint(aiOptions, configuration); bool hasLocalConfig = aiOptions.UseLocalAI @@ -120,6 +122,10 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti else { services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!); + // Bind EmbeddingRetry from config so operator appsettings/env overrides are honored. + // The AIOptions overload of AddAzureOpenAIServices only registers validation, not config binding. + services.AddOptions() + .Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath)); services.AddSingleton(provider => provider.GetRequiredService()); Console.WriteLine("[AI] Selected backend: Azure/Foundry."); return services; @@ -140,7 +146,12 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti { client.BaseAddress = localEndpointUri; client.Timeout = TimeSpan.FromSeconds(120); - }); + }) + // Disable the global standard resilience handler (set by ConfigureHttpClientDefaults + // in Program.cs). Its default attempt timeout (30s) and total timeout (90s) would + // cut off long local-LLM completions. We set HttpClient.Timeout directly instead. + // Retries are also wrong for LLM calls (non-idempotent, partial responses). + .RemoveAllResilienceHandlers(); services.AddSingleton(); Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible)."); return services; @@ -274,4 +285,24 @@ private static string ResolveLocalEndpoint(AIOptions options, IConfiguration con return configuration.GetConnectionString("ollama-chat") ?? string.Empty; } + /// + /// Returns true if can be parsed by + /// and resolves to a non-empty Host. + /// Rejects null, empty, and placeholder strings like "your-postgres-connection-string-here". + /// + private static bool IsValidNpgsqlConnectionString(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return false; + try + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + return !string.IsNullOrWhiteSpace(builder.Host); + } + catch (Exception) + { + return false; + } + } + } From 2527168a2e21de40865afc27397a17f563512dc9 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:56:40 -0700 Subject: [PATCH 05/11] style: remove trailing whitespace from Identity page handlers --- .../Areas/Identity/Pages/Account/Login.cshtml.cs | 4 ++-- .../Areas/Identity/Pages/Account/Register.cshtml.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index f302f7df..16c16154 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; @@ -70,7 +70,7 @@ public async Task OnPostAsync(string? returnUrl = null) string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); - + if (captchaResult?.Success != true) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index f62e53ae..5527cd0c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -96,7 +96,7 @@ public async Task OnPostAsync(string? returnUrl = null) } HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); - + if (response is null) { ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); From 8a0ee1ee4cbea0668e92b1928120f00ba6a69e09 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 20:54:58 -0700 Subject: [PATCH 06/11] 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. --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../LocalChatServiceTests.cs | 161 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 102 +++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs create mode 100644 EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 05b70a11..587ec4f9 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -299,7 +299,7 @@ private static bool IsValidNpgsqlConnectionString(string? connectionString) var builder = new NpgsqlConnectionStringBuilder(connectionString); return !string.IsNullOrWhiteSpace(builder.Host); } - catch (Exception) + catch (ArgumentException) { return false; } diff --git a/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs new file mode 100644 index 00000000..44471508 --- /dev/null +++ b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using EssentialCSharp.Chat; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace EssentialCSharp.Chat.Tests; + +public class LocalChatServiceTests +{ + [Test] + public async Task GetChatCompletion_BuildsExpectedRequest() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-1","choices":[{"message":{"content":"hello back"}}]} + """)); + + var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + + var (response, responseId) = await service.GetChatCompletion("hello"); + + await Assert.That(response).IsEqualTo("hello back"); + await Assert.That(responseId).IsEqualTo("resp-1"); + await Assert.That(requests.Count).IsEqualTo(1); + + var request = requests[0]; + await Assert.That(request.RequestUri!.AbsolutePath).IsEqualTo("/v1/chat/completions"); + await Assert.That(request.Headers.Authorization?.Scheme).IsEqualTo("Bearer"); + await Assert.That(request.Headers.Authorization?.Parameter).IsEqualTo("local-dev-key"); + + string requestBody = await request.Content!.ReadAsStringAsync(); + using var payload = JsonDocument.Parse(requestBody); + await Assert.That(payload.RootElement.GetProperty("model").GetString()).IsEqualTo("qwen2.5-coder:7b"); + await Assert.That(payload.RootElement.GetProperty("stream").GetBoolean()).IsFalse(); + + var messages = payload.RootElement.GetProperty("messages"); + await Assert.That(messages.GetArrayLength()).IsEqualTo(2); + await Assert.That(messages[0].GetProperty("role").GetString()).IsEqualTo("system"); + await Assert.That(messages[0].GetProperty("content").GetString()).IsEqualTo("system prompt"); + await Assert.That(messages[1].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(messages[1].GetProperty("content").GetString()).IsEqualTo("hello"); + } + + [Test] + public async Task GetChatCompletion_WhenBackendReturnsNonSuccess_ThrowsChatBackendUnavailableException() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("upstream error", Encoding.UTF8, "text/plain") + }); + + var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + + await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); + } + + [Test] + public async Task GetChatCompletion_WhenPayloadIsInvalid_ThrowsChatBackendUnavailableException() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse("""{"id":"resp-1","choices":[]}""")); + + var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + + await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); + } + + [Test] + public async Task GetChatCompletion_ReusesConversationHistory_WhenPreviousResponseIdProvided() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-1","choices":[{"message":{"content":"assistant one"}}]} + """)); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-2","choices":[{"message":{"content":"assistant two"}}]} + """)); + + var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + + var first = await service.GetChatCompletion("first"); + _ = await service.GetChatCompletion("second", previousResponseId: first.responseId); + + await Assert.That(requests.Count).IsEqualTo(2); + + string firstBody = await requests[0].Content!.ReadAsStringAsync(); + using var firstPayload = JsonDocument.Parse(firstBody); + var firstMessages = firstPayload.RootElement.GetProperty("messages"); + await Assert.That(firstMessages.GetArrayLength()).IsEqualTo(2); + + string secondBody = await requests[1].Content!.ReadAsStringAsync(); + using var secondPayload = JsonDocument.Parse(secondBody); + var secondMessages = secondPayload.RootElement.GetProperty("messages"); + await Assert.That(secondMessages.GetArrayLength()).IsEqualTo(4); + await Assert.That(secondMessages[1].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(secondMessages[1].GetProperty("content").GetString()).IsEqualTo("first"); + await Assert.That(secondMessages[2].GetProperty("role").GetString()).IsEqualTo("assistant"); + await Assert.That(secondMessages[2].GetProperty("content").GetString()).IsEqualTo("assistant one"); + await Assert.That(secondMessages[3].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(secondMessages[3].GetProperty("content").GetString()).IsEqualTo("second"); + } + + private static LocalChatService CreateService(HttpMessageHandler handler) + { + var options = Options.Create(new AIOptions + { + LocalChatModel = "qwen2.5-coder:7b", + SystemPrompt = "system prompt" + }); + + var client = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost:11434") + }; + + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(f => f.CreateClient("LocalAIChat")) + .Returns(client); + + var logger = Mock.Of>(); + return new LocalChatService(options, httpClientFactory.Object, logger); + } + + private static HttpResponseMessage CreateJsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class RecordingHttpMessageHandler( + List requests, + Queue responses) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var snapshot = new HttpRequestMessage(request.Method, request.RequestUri) + { + Content = request.Content is null + ? null + : new StringContent(await request.Content.ReadAsStringAsync(cancellationToken), Encoding.UTF8, request.Content.Headers.ContentType?.MediaType) + }; + if (request.Headers.Authorization is not null) + { + snapshot.Headers.Authorization = request.Headers.Authorization; + } + + requests.Add(snapshot); + return responses.Dequeue(); + } + } +} diff --git a/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f4709bfa --- /dev/null +++ b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,102 @@ +using EssentialCSharp.Chat.Common.Extensions; +using EssentialCSharp.Chat.Common.Models; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Chat.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Test] + public async Task AddConfiguredChatServices_WithFullAzureConfig_SelectsAzureChatService_AndBindsEmbeddingRetry() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:Endpoint"] = "https://example.openai.azure.com", + ["AIOptions:ChatDeploymentName"] = "chat-deployment", + ["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment", + ["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test", + ["AIOptions:EmbeddingRetry:MaxRetries"] = "7" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationFactory).IsNotNull(); + await Assert.That(services.Any(d => d.ServiceType == typeof(AIChatService))).IsTrue(); + + using var provider = services.BuildServiceProvider(); + var retry = provider.GetRequiredService>().Value; + await Assert.That(retry.MaxRetries).IsEqualTo(7); + } + + [Test] + public async Task AddConfiguredChatServices_WithInvalidAzureEndpoint_FallsBackToLocal_WhenLocalConfigIsValid() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:Endpoint"] = "not-a-valid-uri", + ["AIOptions:ChatDeploymentName"] = "chat-deployment", + ["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment", + ["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test", + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "http://localhost:11434", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithValidLocalConfig_SelectsLocalBackend() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "http://localhost:11434", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithInvalidLocalEndpoint_SelectsUnavailableBackend() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "invalid-uri", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithMissingConfig_SelectsUnavailableBackend() + { + var services = CreateServices(new Dictionary()); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService)); + } + + private static ServiceCollection CreateServices(Dictionary values) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddConfiguredChatServices(configuration); + return services; + } + + private static ServiceDescriptor GetChatServiceDescriptor(IServiceCollection services) => + services.Single(d => d.ServiceType == typeof(IChatCompletionService)); +} From a9bd3ce32a5fb4ebea2d63d723d74ed65a161510 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 21:05:27 -0700 Subject: [PATCH 07/11] test: dispose local test HttpClient/HttpResponseMessage objects - Ensure LocalChatService tests own and dispose created HttpClient instances. - Ensure non-success response object is created in a local using variable before enqueue. --- .../LocalChatServiceTests.cs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs index 44471508..9b2790a6 100644 --- a/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs @@ -20,7 +20,9 @@ public async Task GetChatCompletion_BuildsExpectedRequest() {"id":"resp-1","choices":[{"message":{"content":"hello back"}}]} """)); - var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; var (response, responseId) = await service.GetChatCompletion("hello"); @@ -51,12 +53,15 @@ public async Task GetChatCompletion_WhenBackendReturnsNonSuccess_ThrowsChatBacke { var requests = new List(); var responses = new Queue(); - responses.Enqueue(new HttpResponseMessage(HttpStatusCode.BadGateway) + using var response = new HttpResponseMessage(HttpStatusCode.BadGateway) { Content = new StringContent("upstream error", Encoding.UTF8, "text/plain") - }); + }; + responses.Enqueue(response); - var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); } @@ -68,7 +73,9 @@ public async Task GetChatCompletion_WhenPayloadIsInvalid_ThrowsChatBackendUnavai var responses = new Queue(); responses.Enqueue(CreateJsonResponse("""{"id":"resp-1","choices":[]}""")); - var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); } @@ -85,7 +92,9 @@ public async Task GetChatCompletion_ReusesConversationHistory_WhenPreviousRespon {"id":"resp-2","choices":[{"message":{"content":"assistant two"}}]} """)); - var service = CreateService(new RecordingHttpMessageHandler(requests, responses)); + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; var first = await service.GetChatCompletion("first"); _ = await service.GetChatCompletion("second", previousResponseId: first.responseId); @@ -109,7 +118,7 @@ public async Task GetChatCompletion_ReusesConversationHistory_WhenPreviousRespon await Assert.That(secondMessages[3].GetProperty("content").GetString()).IsEqualTo("second"); } - private static LocalChatService CreateService(HttpMessageHandler handler) + private static (LocalChatService Service, HttpClient Client) CreateService(HttpMessageHandler handler) { var options = Options.Create(new AIOptions { @@ -128,7 +137,7 @@ private static LocalChatService CreateService(HttpMessageHandler handler) .Returns(client); var logger = Mock.Of>(); - return new LocalChatService(options, httpClientFactory.Object, logger); + return (new LocalChatService(options, httpClientFactory.Object, logger), client); } private static HttpResponseMessage CreateJsonResponse(string json) => From 90c85128c31e80af4543b8ae53f46025633b9cba Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 21:37:46 -0700 Subject: [PATCH 08/11] fix: harden unavailable/local chat fallback behavior - Make UnavailableChatService throw ChatBackendUnavailableException for both completion APIs. - Use per-entry MemoryCacheEntryOptions in LocalChatService history cache writes. - Wrap response body-read transport/timeouts as ChatBackendUnavailableException. - Replace project-wide EXTEXP0001 suppression with targeted pragma around RemoveAllResilienceHandlers usage. --- .../EssentialCSharp.Chat.Common.csproj | 3 --- .../Extensions/ServiceCollectionExtensions.cs | 2 ++ .../Services/LocalChatService.cs | 27 +++++++++++++++---- .../Services/UnavailableChatService.cs | 11 +++----- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 8c8728f6..1272ea00 100644 --- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj +++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj @@ -2,9 +2,6 @@ net10.0 - - $(NoWarn);EXTEXP0001 diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 587ec4f9..59134e0d 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -142,6 +142,7 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti return services; } +#pragma warning disable EXTEXP0001 services.AddHttpClient(LocalChatHttpClientName, client => { client.BaseAddress = localEndpointUri; @@ -152,6 +153,7 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti // cut off long local-LLM completions. We set HttpClient.Timeout directly instead. // Retries are also wrong for LLM calls (non-idempotent, partial responses). .RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 services.AddSingleton(); Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible)."); return services; diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs index 81578bee..c77ee1e6 100644 --- a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.IO; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -20,9 +21,6 @@ public partial class LocalChatService : IChatCompletionService, IDisposable private readonly IHttpClientFactory _HttpClientFactory; private readonly ILogger _Logger; private readonly MemoryCache _ConversationHistory = new(new MemoryCacheOptions { SizeLimit = MaxConversationEntries }); - private static readonly MemoryCacheEntryOptions _HistoryEntryOptions = new MemoryCacheEntryOptions() - .SetSlidingExpiration(_HistoryTtl) - .SetSize(1); public bool IsAvailable => true; @@ -76,7 +74,23 @@ public void Dispose() using (response) { - var body = await response.Content.ReadAsStringAsync(cancellationToken); + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (HttpRequestException ex) + { + throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", ex); + } + catch (IOException ex) + { + throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", ex); + } if (!response.IsSuccessStatusCode) { @@ -89,7 +103,10 @@ public void Dispose() var (text, responseId) = ParseResponse(body); history.Add(new LocalChatMessage("user", prompt)); history.Add(new LocalChatMessage("assistant", text)); - _ConversationHistory.Set(responseId, history.TakeLast(MaxConversationMessages).ToList(), _HistoryEntryOptions); + var entryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(_HistoryTtl) + .SetSize(1); + _ConversationHistory.Set(responseId, history.TakeLast(MaxConversationMessages).ToList(), entryOptions); return (text, responseId); } catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) diff --git a/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs index 79981326..ad536960 100644 --- a/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs @@ -20,11 +20,10 @@ public class UnavailableChatService : IChatCompletionService string? endUserId = null, CancellationToken cancellationToken = default) { - return Task.FromResult<(string response, string responseId)>( - ("Chat service is unavailable in this environment.", Guid.NewGuid().ToString("N"))); + throw new ChatBackendUnavailableException("Chat service is unavailable in this environment."); } - public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + public IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( string prompt, string? systemPrompt = null, string? previousResponseId = null, @@ -35,10 +34,8 @@ public class UnavailableChatService : IChatCompletionService #pragma warning restore OPENAI001 bool enableContextualSearch = false, string? endUserId = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) { - yield return ("Chat service is unavailable in this environment.", responseId: null); - yield return (string.Empty, Guid.NewGuid().ToString("N")); - await Task.CompletedTask; + throw new ChatBackendUnavailableException("Chat service is unavailable in this environment."); } } From c8c132876116ce1d6c12e9eb18de0acfe207dbac Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 22:30:12 -0700 Subject: [PATCH 09/11] fix: require https for Azure AI endpoint validation Reject non-HTTPS Azure endpoint values during backend selection to avoid sending DefaultAzureCredential bearer tokens over cleartext transport when misconfigured. --- .../Extensions/ServiceCollectionExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 59134e0d..96444deb 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -115,9 +115,9 @@ public static IServiceCollection AddConfiguredChatServices(this IServiceCollecti // Pre-validate endpoint URI to avoid exceptions in AddAzureOpenAIServices for // non-empty but invalid endpoint values. if (!Uri.TryCreate(aiOptions.Endpoint, UriKind.Absolute, out var azureUri) - || (azureUri.Scheme != Uri.UriSchemeHttp && azureUri.Scheme != Uri.UriSchemeHttps)) + || azureUri.Scheme != Uri.UriSchemeHttps) { - Console.Error.WriteLine("[AI] Azure endpoint is not a valid http/https URI. Falling back to local/unavailable."); + Console.Error.WriteLine("[AI] Azure endpoint must be a valid https URI. Falling back to local/unavailable."); } else { From e69f5338841d4556367f3d88e5f5f05165f751e4 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 01:23:52 -0700 Subject: [PATCH 10/11] test: include hcaptcha test token in chat availability tests Pass the official hCaptcha test token in chat unavailable endpoint tests so captcha enforcement does not short-circuit with 403 before backend-availability assertions. --- EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs index 23c4589d..d79a675e 100644 --- a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs +++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs @@ -9,6 +9,8 @@ namespace EssentialCSharp.Web.Tests; [ClassDataSource(Shared = SharedType.PerClass)] public class ChatAvailabilityTests(WebApplicationFactory factory) { + private const string HCaptchaTestToken = "10000000-aaaa-bbbb-cccc-000000000001"; + [Test] public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() { @@ -19,7 +21,7 @@ public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/message") { - Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false }) + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse = HCaptchaTestToken }) }; McpTestHelper.AddCookie(request, cookieName, cookieValue); @@ -40,7 +42,7 @@ public async Task ChatStream_WhenBackendUnavailable_Returns503WithContract() using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream") { - Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false }) + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse = HCaptchaTestToken }) }; McpTestHelper.AddCookie(request, cookieName, cookieValue); From 148b376195b0152c03faa5335567990d757f10fe Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 09:31:35 -0700 Subject: [PATCH 11/11] fix: update ChatAvailabilityTests to use IntegrationTestBase pattern Migrate ChatAvailabilityTests from the old [ClassDataSource] pattern to the new IntegrationTestBase base class introduced by PR #1100. - Inherit from IntegrationTestBase instead of using ClassDataSource attribute - Use McpTestHelper.CreateClient(Factory) instead of factory.CreateClient(...) - Use this.Factory (TracedWebApplicationFactory) via inherited property - Remove unused WebApplicationFactoryClientOptions import --- .../ChatAvailabilityTests.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs index d79a675e..6b5528c4 100644 --- a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs +++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs @@ -1,23 +1,21 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; -using Microsoft.AspNetCore.Mvc.Testing; namespace EssentialCSharp.Web.Tests; [NotInParallel("ChatAvailabilityTests")] -[ClassDataSource(Shared = SharedType.PerClass)] -public class ChatAvailabilityTests(WebApplicationFactory factory) +public class ChatAvailabilityTests : IntegrationTestBase { private const string HCaptchaTestToken = "10000000-aaaa-bbbb-cccc-000000000001"; [Test] public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() { - HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + HttpClient client = McpTestHelper.CreateClient(Factory); - string userId = await McpTestHelper.CreateUserAsync(factory, "chat-unavailable"); - (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, userId); + string userId = await McpTestHelper.CreateUserAsync(Factory, "chat-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/message") { @@ -35,10 +33,10 @@ public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() [Test] public async Task ChatStream_WhenBackendUnavailable_Returns503WithContract() { - HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + HttpClient client = McpTestHelper.CreateClient(Factory); - string userId = await McpTestHelper.CreateUserAsync(factory, "chat-stream-unavailable"); - (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, userId); + string userId = await McpTestHelper.CreateUserAsync(Factory, "chat-stream-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream") {