Skip to content

Commit 91aa472

Browse files
Fix PR review comments: health endpoint test, AppInsights config, FileProvider disposal, retry tests
- Fix CI failure: update FunctionalTests.cs to use /health and /alive instead of removed /healthz - Fix AppInsights silent failure: check APPLICATIONINSIGHTS_CONNECTION_STRING, ApplicationInsights:ConnectionString, and GetConnectionString('ApplicationInsights') fallback; pass resolved string explicitly to UseAzureMonitor(options => ...) since the no-arg overload only auto-reads the flat env var. Deployment workflow sets ApplicationInsights__ConnectionString which maps to the hierarchical key, not the flat env var. - Fix PhysicalFileProvider IDisposable leak: ListingSourceCodeService implements IDisposable with _OwnsFileProvider tracking; only disposes the provider it creates, not the framework-managed ContentRootFileProvider; GC.SuppressFinalize included per CA1816. - Add AISearchService retry unit tests: 4 tests covering happy path (no retry), 28000 retry success, non-28000 pass-through, and both-attempts-fail propagation. PostgresException has a public constructor so no reflection or seams are needed.
1 parent 69b342c commit 91aa472

4 files changed

Lines changed: 146 additions & 4 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using EssentialCSharp.Chat.Common.Models;
2+
using EssentialCSharp.Chat.Common.Services;
3+
using Microsoft.Extensions.AI;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.VectorData;
6+
using Moq;
7+
using Npgsql;
8+
9+
namespace EssentialCSharp.Chat.Tests;
10+
11+
public class AISearchServiceTests
12+
{
13+
private static readonly BookContentChunk _TestChunk = new() { Id = "test-1", ChunkText = "test" };
14+
15+
private static (AISearchService svc, Mock<VectorStoreCollection<string, BookContentChunk>> collectionMock)
16+
CreateService()
17+
{
18+
var collectionMock = new Mock<VectorStoreCollection<string, BookContentChunk>>();
19+
20+
var vectorStoreMock = new Mock<VectorStore>();
21+
vectorStoreMock
22+
.Setup(vs => vs.GetCollection<string, BookContentChunk>(It.IsAny<string>(), It.IsAny<VectorStoreCollectionDefinition?>()))
23+
.Returns(collectionMock.Object);
24+
25+
// IEmbeddingGenerator<string, Embedding<float>>.GenerateAsync is the batch interface method
26+
// called internally by the single-value extension used in EmbeddingService.GenerateEmbeddingAsync.
27+
var embGenMock = new Mock<IEmbeddingGenerator<string, Embedding<float>>>();
28+
embGenMock
29+
.Setup(g => g.GenerateAsync(
30+
It.IsAny<IEnumerable<string>>(),
31+
It.IsAny<EmbeddingGenerationOptions?>(),
32+
It.IsAny<CancellationToken>()))
33+
.ReturnsAsync(new GeneratedEmbeddings<Embedding<float>>([new Embedding<float>(new float[1536])]));
34+
35+
var embeddingService = new EmbeddingService(vectorStoreMock.Object, embGenMock.Object);
36+
var loggerMock = new Mock<ILogger<AISearchService>>();
37+
38+
return (new AISearchService(vectorStoreMock.Object, embeddingService, loggerMock.Object), collectionMock);
39+
}
40+
41+
private static async IAsyncEnumerable<VectorSearchResult<BookContentChunk>> OneResultStream()
42+
{
43+
yield return new VectorSearchResult<BookContentChunk>(_TestChunk, 0.9f);
44+
await Task.CompletedTask;
45+
}
46+
47+
[Test]
48+
public async Task ExecuteVectorSearch_HappyPath_ReturnsResultsWithoutRetry()
49+
{
50+
var (svc, collectionMock) = CreateService();
51+
int callCount = 0;
52+
53+
collectionMock
54+
.Setup(c => c.SearchAsync(
55+
It.IsAny<ReadOnlyMemory<float>>(),
56+
It.IsAny<int>(),
57+
It.IsAny<VectorSearchOptions<BookContentChunk>?>(),
58+
It.IsAny<CancellationToken>()))
59+
.Returns(() => { callCount++; return OneResultStream(); });
60+
61+
var results = await svc.ExecuteVectorSearch("test query");
62+
63+
await Assert.That(results.Count).IsEqualTo(1);
64+
await Assert.That(callCount).IsEqualTo(1);
65+
}
66+
67+
[Test]
68+
public async Task ExecuteVectorSearch_RetriesOnce_WhenFirstAttemptThrows28000()
69+
{
70+
var (svc, collectionMock) = CreateService();
71+
int callCount = 0;
72+
73+
collectionMock
74+
.Setup(c => c.SearchAsync(
75+
It.IsAny<ReadOnlyMemory<float>>(),
76+
It.IsAny<int>(),
77+
It.IsAny<VectorSearchOptions<BookContentChunk>?>(),
78+
It.IsAny<CancellationToken>()))
79+
.Returns(() =>
80+
{
81+
callCount++;
82+
if (callCount == 1)
83+
throw new PostgresException("auth token expired", "FATAL", "FATAL", "28000");
84+
return OneResultStream();
85+
});
86+
87+
var results = await svc.ExecuteVectorSearch("test query");
88+
89+
await Assert.That(results.Count).IsEqualTo(1);
90+
await Assert.That(callCount).IsEqualTo(2);
91+
}
92+
93+
[Test]
94+
public async Task ExecuteVectorSearch_DoesNotRetry_WhenSqlStateIsNot28000()
95+
{
96+
var (svc, collectionMock) = CreateService();
97+
98+
collectionMock
99+
.Setup(c => c.SearchAsync(
100+
It.IsAny<ReadOnlyMemory<float>>(),
101+
It.IsAny<int>(),
102+
It.IsAny<VectorSearchOptions<BookContentChunk>?>(),
103+
It.IsAny<CancellationToken>()))
104+
.Returns(() => throw new PostgresException("table not found", "ERROR", "ERROR", "42P01"));
105+
106+
await Assert.ThrowsAsync<PostgresException>(() => svc.ExecuteVectorSearch("test query"));
107+
}
108+
109+
[Test]
110+
public async Task ExecuteVectorSearch_PropagatesException_WhenBothAttemptsFail28000()
111+
{
112+
var (svc, collectionMock) = CreateService();
113+
114+
collectionMock
115+
.Setup(c => c.SearchAsync(
116+
It.IsAny<ReadOnlyMemory<float>>(),
117+
It.IsAny<int>(),
118+
It.IsAny<VectorSearchOptions<BookContentChunk>?>(),
119+
It.IsAny<CancellationToken>()))
120+
.Returns(() => throw new PostgresException("auth failed", "FATAL", "FATAL", "28000"));
121+
122+
await Assert.ThrowsAsync<PostgresException>(() => svc.ExecuteVectorSearch("test query"));
123+
}
124+
}

EssentialCSharp.Web.Tests/FunctionalTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ public class FunctionalTests(WebApplicationFactory factory)
1111
[Arguments("/hello-world")]
1212
[Arguments("/hello-world#hello-world")]
1313
[Arguments("/guidelines")]
14-
[Arguments("/healthz")]
14+
[Arguments("/health")]
15+
[Arguments("/alive")]
1516
public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl)
1617
{
1718
HttpClient client = factory.CreateClient();

EssentialCSharp.Web/Program.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ private static void Main(string[] args)
3838
// Production: Azure Monitor (Application Insights) via APPLICATIONINSIGHTS_CONNECTION_STRING
3939
// Local/Aspire: OTLP to Aspire Dashboard via OTEL_EXPORTER_OTLP_ENDPOINT
4040
// Never both simultaneously — that would cause duplicate telemetry in App Insights.
41-
bool useAzureMonitor = !string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]);
41+
// Resolve from all config locations:
42+
// 1. Standard Azure Monitor flat env var (SDK's own default key)
43+
// 2. Double-underscore (hierarchical) format set by the deployment workflow
44+
// 3. GetConnectionString fallback
45+
string? appInsightsConnectionString =
46+
builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]
47+
?? builder.Configuration["ApplicationInsights:ConnectionString"]
48+
?? builder.Configuration.GetConnectionString("ApplicationInsights");
49+
bool useAzureMonitor = !string.IsNullOrWhiteSpace(appInsightsConnectionString);
4250
bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
4351

4452
builder.Logging.AddOpenTelemetry(logging =>
@@ -80,7 +88,7 @@ private static void Main(string[] args)
8088
});
8189

8290
if (useAzureMonitor)
83-
otel.UseAzureMonitor();
91+
otel.UseAzureMonitor(options => options.ConnectionString = appInsightsConnectionString);
8492
else if (useOtlp)
8593
otel.UseOtlpExporter();
8694

EssentialCSharp.Web/Services/ListingSourceCodeService.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
namespace EssentialCSharp.Web.Services;
77

8-
public partial class ListingSourceCodeService : IListingSourceCodeService
8+
public partial class ListingSourceCodeService : IListingSourceCodeService, IDisposable
99
{
1010
private readonly IFileProvider _FileProvider;
11+
private readonly bool _OwnsFileProvider;
1112
private readonly string _ChapterDirectoryPrefix;
1213
private readonly ILogger<ListingSourceCodeService> _Logger;
1314

@@ -19,6 +20,7 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger<
1920
if (!string.IsNullOrEmpty(listingSourceCodePath) && Directory.Exists(listingSourceCodePath))
2021
{
2122
_FileProvider = new PhysicalFileProvider(listingSourceCodePath);
23+
_OwnsFileProvider = true;
2224
_ChapterDirectoryPrefix = "src";
2325
_Logger.LogInformation("Using listing source code from: {Path}", listingSourceCodePath);
2426
}
@@ -29,6 +31,13 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger<
2931
}
3032
}
3133

34+
public void Dispose()
35+
{
36+
if (_OwnsFileProvider && _FileProvider is IDisposable disposable)
37+
disposable.Dispose();
38+
GC.SuppressFinalize(this);
39+
}
40+
3241
public async Task<ListingSourceCodeResponse?> GetListingAsync(int chapterNumber, int listingNumber)
3342
{
3443
string chapterDirectory = $"{_ChapterDirectoryPrefix}/Chapter{chapterNumber:D2}";

0 commit comments

Comments
 (0)