Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions StorageLens.sln
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StorageLens.Web.Tests", "tests\StorageLens.Web.Tests\StorageLens.Web.Tests.csproj", "{14CC635E-B00B-4B0D-8DD0-21B2065F069C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StorageLens.Shared.Infrastructure.Tests", "tests\StorageLens.Shared.Infrastructure.Tests\StorageLens.Shared.Infrastructure.Tests.csproj", "{F799C900-AA91-4972-9857-0EAF56F8420B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -185,6 +187,18 @@ Global
{14CC635E-B00B-4B0D-8DD0-21B2065F069C}.Release|x64.Build.0 = Release|Any CPU
{14CC635E-B00B-4B0D-8DD0-21B2065F069C}.Release|x86.ActiveCfg = Release|Any CPU
{14CC635E-B00B-4B0D-8DD0-21B2065F069C}.Release|x86.Build.0 = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|x64.ActiveCfg = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|x64.Build.0 = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|x86.ActiveCfg = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Debug|x86.Build.0 = Debug|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|Any CPU.Build.0 = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|x64.ActiveCfg = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|x64.Build.0 = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|x86.ActiveCfg = Release|Any CPU
{F799C900-AA91-4972-9857-0EAF56F8420B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -202,5 +216,6 @@ Global
{74E7A364-4737-46B1-A165-BFBC496167F4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A86C23F7-97EE-4979-9863-67F97850BF1A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{14CC635E-B00B-4B0D-8DD0-21B2065F069C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{F799C900-AA91-4972-9857-0EAF56F8420B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ The format is inspired by Keep a Changelog, and this project follows Semantic Ve
- **Duplicate-Heavy Locations** panel redesigned from a plain list to a ranked visual impact view with rank badges, share percentages, and proportional impact bars.

### Added
- **`StorageLens.Shared.Infrastructure.Tests` project** — new xUnit test project covering the HTTP client policy pipeline behaviour:
- `HttpClientPolicyPipelineIntegrationTests` with three integration tests verifying that `CorrelationIdDelegatingHandler` is correctly resolved from DI and properly adds the `X-Correlation-Id` header to outgoing requests, does not overwrite an already-present header, and generates a unique ID per request.
- **`CorrelationIdDelegatingHandler`** class added to `StorageLens.Shared.Infrastructure` — a `DelegatingHandler` that automatically attaches a `X-Correlation-Id` header to every outgoing HTTP request when the header is not already present, enabling end-to-end correlation tracing across services.
Comment on lines +21 to +23
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changelog bullets document registering CorrelationIdDelegatingHandler as a singleton. For IHttpClientFactory + AddHttpMessageHandler<T>(), singleton lifetime is unsafe; update the changelog to reflect the correct transient (or factory-based) registration so docs match the intended DI configuration.

Copilot uses AI. Check for mistakes.
- **Chart details modal patterns** on Dashboard and Reports pages for chart click-drill interactions.
- **Interaction hints** on chart headers (e.g., "Click bars for details", "Click points for details") to make interactivity discoverable.
- **Alerts page** (`/Alerts`) with adjustable min/max threshold sliders, test recipient support, and automatic threshold-check email dispatch when usage reaches the max threshold.
- **SMTP alert service** (`SmtpAlertEmailService`) with cooldown protection to avoid repeated email sends on page refresh.
- **`AlertsModelTests`** unit test validating that threshold crossing triggers an email dispatch call.

### Fixed
- **`CorrelationIdDelegatingHandler` DI registration** — added `services.AddSingleton<CorrelationIdDelegatingHandler>()` in `HttpClientPolicyPipelineIntegrationTests` so the handler is resolvable when `AddHttpMessageHandler<T>()` activates it through the DI container. Previously tests threw `System.InvalidOperationException: No service for type 'StorageLens.Shared.Infrastructure.CorrelationIdDelegatingHandler' has been registered.`
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entry specifically calls out adding services.AddSingleton<CorrelationIdDelegatingHandler>() in tests. Since DelegatingHandler should be transient when activated via AddHttpMessageHandler<T>(), the fix description should be updated accordingly (e.g., AddTransient<CorrelationIdDelegatingHandler>()).

Suggested change
- **`CorrelationIdDelegatingHandler` DI registration** — added `services.AddSingleton<CorrelationIdDelegatingHandler>()` in `HttpClientPolicyPipelineIntegrationTests` so the handler is resolvable when `AddHttpMessageHandler<T>()` activates it through the DI container. Previously tests threw `System.InvalidOperationException: No service for type 'StorageLens.Shared.Infrastructure.CorrelationIdDelegatingHandler' has been registered.`
- **`CorrelationIdDelegatingHandler` DI registration** — added `services.AddTransient<CorrelationIdDelegatingHandler>()` in `HttpClientPolicyPipelineIntegrationTests` so the handler is resolvable when `AddHttpMessageHandler<T>()` activates it through the DI container. Previously tests threw `System.InvalidOperationException: No service for type 'StorageLens.Shared.Infrastructure.CorrelationIdDelegatingHandler' has been registered.`

Copilot uses AI. Check for mistakes.
- **Service-unavailable error handling** — all API client methods (`LocationsApiClient`, `ScanJobsApiClient`, `FileInventoryApiClient`, `DuplicatesApiClient`, `AnalyticsApiClient`, `OrchestratorApiClient`) now catch `HttpRequestException` and log a warning instead of propagating the exception to the error page. Pages that fail to load backend data now display a clear yellow alert banner rather than crashing.
- **`DuplicatesService` build error** — `GroupBy` result was projected to `List<FileRecordDto>` via `.Select(g => g.OrderByDescending(...).ToList())`, causing the group key to be lost. The select now projects to a named tuple `(Key, Files)` so `HashValue` and the final `duplicateHashValues` collection are correctly populated.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace StorageLens.Shared.Infrastructure;

public class CorrelationIdDelegatingHandler : DelegatingHandler
{
public const string HeaderName = "X-Correlation-Id";

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!request.Headers.Contains(HeaderName))
{
request.Headers.TryAddWithoutValidation(HeaderName, Guid.NewGuid().ToString("N"));
}

return base.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using StorageLens.Shared.Infrastructure;
using Xunit;

namespace StorageLens.Shared.Infrastructure.Tests;

public class HttpClientPolicyPipelineIntegrationTests
{
[Fact]
public async Task HttpClient_WithCorrelationIdHandler_AddsCorrelationIdHeader()
{
HttpRequestMessage? capturedRequest = null;

var services = new ServiceCollection();
services.AddSingleton<CorrelationIdDelegatingHandler>();
services.AddHttpClient("test")
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>()
Comment on lines +15 to +18
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CorrelationIdDelegatingHandler should not be registered as a singleton when used with IHttpClientFactory + AddHttpMessageHandler<T>(). DelegatingHandler instances are expected to be transient; singleton reuse can break when the HttpMessageHandler pipeline is rebuilt (e.g., after the default handler lifetime), because the same handler instance may already have an InnerHandler assigned. Register it as transient instead (or via the overload that provides a factory).

Copilot uses AI. Check for mistakes.
.ConfigurePrimaryHttpMessageHandler(() => new StubHttpMessageHandler(req =>
{
capturedRequest = req;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}));

var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("test");

await client.GetAsync("http://localhost/test");

Assert.NotNull(capturedRequest);
Assert.True(capturedRequest.Headers.Contains(CorrelationIdDelegatingHandler.HeaderName));
}

[Fact]
public async Task HttpClient_WithCorrelationIdHandler_DoesNotOverwriteExistingHeader()
{
HttpRequestMessage? capturedRequest = null;
const string existingCorrelationId = "existing-correlation-id";

var services = new ServiceCollection();
services.AddSingleton<CorrelationIdDelegatingHandler>();
services.AddHttpClient("test")
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>()
.ConfigurePrimaryHttpMessageHandler(() => new StubHttpMessageHandler(req =>
Comment on lines +41 to +45
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: registering CorrelationIdDelegatingHandler as singleton is unsafe with AddHttpMessageHandler<T>() because the handler instance can be reused across pipeline rebuilds. Use a transient registration for the handler.

Copilot uses AI. Check for mistakes.
{
capturedRequest = req;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}));

var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("test");

var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test");
request.Headers.TryAddWithoutValidation(CorrelationIdDelegatingHandler.HeaderName, existingCorrelationId);
await client.SendAsync(request);

Assert.NotNull(capturedRequest);
var headerValues = capturedRequest.Headers.GetValues(CorrelationIdDelegatingHandler.HeaderName);
Assert.Equal(existingCorrelationId, headerValues.Single());
}

[Fact]
public async Task HttpClient_WithCorrelationIdHandler_GeneratesUniqueIdsPerRequest()
{
var correlationIds = new List<string>();

var services = new ServiceCollection();
services.AddSingleton<CorrelationIdDelegatingHandler>();
services.AddHttpClient("test")
.AddHttpMessageHandler<CorrelationIdDelegatingHandler>()
.ConfigurePrimaryHttpMessageHandler(() => new StubHttpMessageHandler(req =>
Comment on lines +69 to +73
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same lifetime issue: DelegatingHandler should be transient when resolved by IHttpClientFactory. A singleton handler can cause failures when the handler pipeline rotates/rebuilds. Register CorrelationIdDelegatingHandler as transient instead.

Copilot uses AI. Check for mistakes.
{
var id = req.Headers.GetValues(CorrelationIdDelegatingHandler.HeaderName).First();
correlationIds.Add(id);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}));

var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("test");

await client.GetAsync("http://localhost/test");
await client.GetAsync("http://localhost/test");

Assert.Equal(2, correlationIds.Count);
Assert.NotEqual(correlationIds[0], correlationIds[1]);
}

private sealed class StubHttpMessageHandler(
Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> handler(request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package version drift: the repo generally pins Microsoft.* packages to 8.0.12 (e.g., other test projects and Microsoft.Extensions.Http.Polly). Consider bumping Microsoft.Extensions.Http from 8.0.1 to 8.0.12 here to keep dependencies consistent and reduce the chance of transitive version conflicts.

Suggested change
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.12" />

Copilot uses AI. Check for mistakes.
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\StorageLens.Shared.Infrastructure\StorageLens.Shared.Infrastructure.csproj" />
</ItemGroup>

</Project>
Loading