Skip to content

P8: Add end-to-end integration tests#7

Merged
jhosm merged 3 commits intomainfrom
p8/add-integration-tests
Feb 14, 2026
Merged

P8: Add end-to-end integration tests#7
jhosm merged 3 commits intomainfrom
p8/add-integration-tests

Conversation

@jhosm
Copy link
Owner

@jhosm jhosm commented Feb 14, 2026

Summary

  • Add 20 integration tests using WireMock.Net fake servers (FakeOAuthServer, FakeApiServer) that validate the complete ACDC handler pipeline end-to-end
  • Tests cover: full pipeline flow, auth lifecycle (proactive/reactive refresh, concurrent queue, logout), cache integration (ETag/304, SWR, mutation invalidation, user isolation), builder reusability, cancel-all with recovery, error classification, and timeout behavior
  • All 20 integration tests pass; all 301 existing unit tests continue to pass (320 total)

Test plan

  • dotnet build succeeds
  • dotnet test tests/CSharpAcdc.IntegrationTests -- 20/20 pass
  • dotnet test (full suite) -- 320/320 pass
  • All tasks in openspec/changes/add-integration-tests/tasks.md marked complete

Files changed

  • tests/CSharpAcdc.IntegrationTests/Helpers/FakeOAuthServer.cs -- WireMock OAuth server
  • tests/CSharpAcdc.IntegrationTests/Helpers/FakeApiServer.cs -- WireMock API server
  • tests/CSharpAcdc.IntegrationTests/CompleteClientIntegrationTests.cs -- Pipeline + error tests
  • tests/CSharpAcdc.IntegrationTests/AuthLifecycleTests.cs -- Auth flow tests
  • tests/CSharpAcdc.IntegrationTests/CacheIntegrationTests.cs -- Cache flow tests
  • tests/CSharpAcdc.IntegrationTests/BuilderReusabilityTests.cs -- Builder independence tests
  • tests/CSharpAcdc.IntegrationTests/CancelAllTests.cs -- Cancellation tests
  • tests/CSharpAcdc.IntegrationTests/CSharpAcdc.IntegrationTests.csproj -- Added JWT package ref
  • openspec/changes/add-integration-tests/tasks.md -- All items marked [x]

🤖 Generated with Claude Code

Add 20 integration tests using WireMock.Net fake servers covering:
- Full pipeline flow through all handlers (Logging->Error->Cancel->Auth->Cache->Dedup)
- Auth lifecycle: proactive refresh, reactive 401 retry, concurrent queue, logout
- Cache integration: ETag/304 round-trip, SWR, mutation invalidation, user isolation
- Builder reusability with independent keyed clients
- CancelAll with recovery
- Error classification (401/403/4xx/5xx) and timeout through full pipeline

Test helpers: FakeOAuthServer (/token, /revoke with call tracking) and
FakeApiServer (dynamic handlers, 401-then-success, ETag/304, configurable latency).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 14, 2026 18:51
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive end-to-end integration tests for the ACDC HTTP client library using WireMock.Net to simulate OAuth and API servers. The tests validate the complete request pipeline including authentication, caching, error handling, cancellation, and builder patterns.

Changes:

  • Added 20 integration tests covering full pipeline flow, auth lifecycle (proactive/reactive refresh, concurrent operations, logout), cache integration (ETag/304, stale-while-revalidate, mutation invalidation, user isolation), builder reusability, cancel-all with recovery, error classification, and timeout behavior
  • Implemented WireMock-backed fake servers (FakeOAuthServer, FakeApiServer) with configurable responses, ETag support, and request tracking capabilities
  • Added System.IdentityModel.Tokens.Jwt package reference to support JWT creation for user isolation tests

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 21 comments.

Show a summary per file
File Description
tests/CSharpAcdc.IntegrationTests/Helpers/FakeOAuthServer.cs WireMock OAuth server with /token and /revoke endpoints supporting success, invalid_grant, and server error scenarios
tests/CSharpAcdc.IntegrationTests/Helpers/FakeApiServer.cs WireMock API server with dynamic handlers, ETag/304 support, configurable delays, and request tracking
tests/CSharpAcdc.IntegrationTests/CompleteClientIntegrationTests.cs 7 tests validating full pipeline flow, bearer token injection, error classification (401, 403, 4xx, 5xx), and timeout behavior
tests/CSharpAcdc.IntegrationTests/AuthLifecycleTests.cs 4 tests covering proactive refresh, reactive 401 retry, concurrent refresh queue deduplication, and logout handling
tests/CSharpAcdc.IntegrationTests/CacheIntegrationTests.cs 4 tests validating ETag/If-None-Match round-trip, stale-while-revalidate, POST mutation invalidation, and user-isolated caching
tests/CSharpAcdc.IntegrationTests/BuilderReusabilityTests.cs 3 tests verifying builder immutability and independence of multiple client instances
tests/CSharpAcdc.IntegrationTests/CancelAllTests.cs 2 tests confirming CancelAll cancels in-flight requests and allows new requests afterward
tests/CSharpAcdc.IntegrationTests/CSharpAcdc.IntegrationTests.csproj Added System.IdentityModel.Tokens.Jwt package reference for JWT token creation in tests
openspec/changes/add-integration-tests/tasks.md Marked all 20 integration test tasks as complete

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

client.CancelAll();

// Wait for cancellation to propagate
try { await slowTask; } catch { /* expected */ }
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The catch block swallows all exceptions without any specificity. While this works for cleanup purposes in a test, it would be more explicit to catch the specific exception type expected (AcdcNetworkException) or at least document what exception is expected in the comment.

Suggested change
try { await slowTask; } catch { /* expected */ }
try { await slowTask; } catch (AcdcNetworkException) { /* expected network timeout due to CancelAll */ }

Copilot uses AI. Check for mistakes.
public void Dispose()
{
_server.Stop();
_server.Dispose();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The Dispose method should call GC.SuppressFinalize(this) to be consistent with other test classes in the codebase. This prevents the finalizer from running unnecessarily when the object has already been disposed.

Suggested change
_server.Dispose();
_server.Dispose();
System.GC.SuppressFinalize(this);

Copilot uses AI. Check for mistakes.
}

public void Dispose()
{
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The Dispose method should call GC.SuppressFinalize(this) to be consistent with other test classes in the codebase. This prevents the finalizer from running unnecessarily when the object has already been disposed.

Suggested change
{
{
GC.SuppressFinalize(this);

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,178 @@
using CSharpAcdc.Auth;
using CSharpAcdc.Builder;
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The using statement for CSharpAcdc.Builder appears to be unnecessary. The builder type is inferred from the lambda parameter in AddAcdcHttpClient, so this import can be removed.

Suggested change
using CSharpAcdc.Builder;

Copilot uses AI. Check for mistakes.

services.AddKeyedSingleton<ITokenProvider>("acdc", tokenProvider);

var sp = services.BuildServiceProvider();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The ServiceProvider instance created with services.BuildServiceProvider() implements IDisposable and should be disposed to prevent resource leaks. Consider wrapping it in a using statement or disposing it after extracting the required services.

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +252
public void Dispose()
{
_api.Dispose();
_oauth.Dispose();
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The Dispose method should call GC.SuppressFinalize(this) to be consistent with other test classes in the codebase. This prevents the finalizer from running unnecessarily when the object has already been disposed.

Copilot uses AI. Check for mistakes.
Request.Create()
.WithPath(path)
.UsingGet()
.WithHeader("If-None-Match", $"\"\\\"" + etag + "\\\"\"", WireMock.Matchers.MatchBehaviour.AcceptOnMatch))
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The header matching pattern for If-None-Match appears overly complex with multiple layers of escaping: $"\"\\\"" + etag + "\\\"\"". This constructs a pattern like "\"abc123\"" to match an ETag. Consider simplifying this to be more readable and maintainable. The standard ETag format is "value" (with quotes), and the If-None-Match header should contain that exact value. A simpler pattern like $"\"{etag}\"" or using a more straightforward WireMock matcher would be clearer.

Suggested change
.WithHeader("If-None-Match", $"\"\\\"" + etag + "\\\"\"", WireMock.Matchers.MatchBehaviour.AcceptOnMatch))
.WithHeader("If-None-Match", $"\"{etag}\"", WireMock.Matchers.MatchBehaviour.AcceptOnMatch))

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +40
// Verify token refresh was called
Assert.True(_oauth.GetCallCount("/token") >= 1);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The test comment states "Verify token refresh was called" and checks that call count is >= 1, but a more precise assertion would be to verify exactly 1 call was made. The fire-and-forget nature of proactive refresh means the refresh should have completed within the 500ms delay. Consider using Assert.Equal(1, _oauth.GetCallCount("/token")) for a more precise assertion.

Suggested change
// Verify token refresh was called
Assert.True(_oauth.GetCallCount("/token") >= 1);
// Verify token refresh was called exactly once
Assert.Equal(1, _oauth.GetCallCount("/token"));

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +70
var sp = services.BuildServiceProvider();
return sp.GetRequiredService<AcdcHttpClient>();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The ServiceProvider instance created with services.BuildServiceProvider() implements IDisposable and should be disposed to prevent resource leaks. Consider wrapping it in a using statement or storing it in a field and disposing it in the test class's Dispose method.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +30
var sp1 = services1.BuildServiceProvider();
var sp2 = services2.BuildServiceProvider();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

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

The ServiceProvider instances created with BuildServiceProvider() implement IDisposable and should be disposed to prevent resource leaks. Consider wrapping them in using statements or storing them and disposing in the test class's Dispose method.

Copilot uses AI. Check for mistakes.
@jhosm
Copy link
Owner Author

jhosm commented Feb 14, 2026

Code Review: P8 -- Add end-to-end integration tests

Files Reviewed

  • tests/CSharpAcdc.IntegrationTests/Helpers/FakeOAuthServer.cs
  • tests/CSharpAcdc.IntegrationTests/Helpers/FakeApiServer.cs
  • tests/CSharpAcdc.IntegrationTests/CompleteClientIntegrationTests.cs
  • tests/CSharpAcdc.IntegrationTests/AuthLifecycleTests.cs
  • tests/CSharpAcdc.IntegrationTests/CacheIntegrationTests.cs
  • tests/CSharpAcdc.IntegrationTests/BuilderReusabilityTests.cs
  • tests/CSharpAcdc.IntegrationTests/CancelAllTests.cs
  • tests/CSharpAcdc.IntegrationTests/CSharpAcdc.IntegrationTests.csproj
  • openspec/changes/add-integration-tests/tasks.md

Overall Assessment

This is a solid set of integration tests. The test helpers are well-designed, the WireMock usage is correct (scenarios, priorities, delays), and the tests cover the critical flows from the P8 task list. Good use of the production DI pipeline (AddAcdcHttpClient) rather than manually wiring handlers, which validates the real registration path.

I found 2 Critical and 3 Important issues. I am excluding items already flagged by the Copilot automated review (ServiceProvider disposal, GC.SuppressFinalize, unused import, JWT key extraction) since those are already tracked.


Critical Issues (Confidence 90-100)

1. ConcurrentRefreshQueue_OnlyOneRefreshCall does not actually test concurrency (Confidence: 95)

File: tests/CSharpAcdc.IntegrationTests/AuthLifecycleTests.cs, lines 70-94

The task spec says: "send N simultaneous requests that all receive 401, verify only 1 token refresh call is made to the OAuth server." The test sends a single request instead of N concurrent ones. A single request hitting 401 and retrying will trivially make exactly 1 refresh call -- it proves nothing about the concurrent refresh queue (the leader/follower pattern with Interlocked.CompareExchange on _pendingRefresh in AuthHandler).

The WireMock scenario also creates a problem here: RespondWith401ThenSuccess uses a single named scenario ("Auth-Retry") which transitions after the first 401. With concurrent requests you would need multiple 401s before the state transitions.

Suggested fix: Send N concurrent requests (e.g., 5-10) that all hit a 401, then verify that only 1 token refresh call is made. You will need to configure the API server to return 401 for the first N requests (not just the first one) -- either via a separate WireMock scenario setup or by using a counter-based approach. For example:

[Fact]
public async Task ConcurrentRefreshQueue_OnlyOneRefreshCall()
{
    _oauth.ConfigureTokenSuccess("concurrent-token", "concurrent-refresh", 3600);
    
    // Configure API to always return 401 initially, then success once authenticated
    // Need a setup where N concurrent requests all see 401
    // ... (configure appropriately for concurrent 401s)

    var tokenProvider = new InMemoryTokenProvider();
    await tokenProvider.SaveTokensAsync(
        "stale-token", "valid-refresh",
        DateTimeOffset.UtcNow.AddHours(1),
        CancellationToken.None);

    using var client = BuildClient(tokenProvider: tokenProvider);

    // Send N concurrent requests
    var tasks = Enumerable.Range(0, 5)
        .Select(_ => client.GetAsync($"{_api.Url}/concurrent"))
        .ToArray();

    var responses = await Task.WhenAll(tasks);
    
    // All should succeed (or at least the auth handler should coalesce)
    // The key assertion: only 1 refresh call
    Assert.Equal(1, _oauth.GetCallCount("/token"));
}

This is the test that validates the #1 concern from CLAUDE.md ("Thread safety is the #1 concern") for the auth refresh queue, so getting it right matters.


2. LogoutDuringRefresh_HandlesGracefully does not test logout during an active refresh (Confidence: 92)

File: tests/CSharpAcdc.IntegrationTests/AuthLifecycleTests.cs, lines 96-135

The task spec says: "verify graceful handling when logout is triggered while a token refresh is in progress." The test simply calls LogoutAsync in isolation -- no refresh is in progress. It is effectively a "logout works" test, not a "logout during active refresh" test.

To test the actual scenario, you would need to:

  1. Trigger a token refresh (e.g., via a request that hits 401, or a proactive refresh)
  2. While that refresh is still in-flight, call LogoutAsync
  3. Verify the system handles the race gracefully (no deadlock, no crash, tokens end up cleared)

This could be done by adding a delay to the OAuth /token response so the refresh takes time, then calling logout concurrently.


Important Issues (Confidence 80-89)

3. Hardcoded scenario name in RespondWith401ThenSuccess breaks multi-path usage (Confidence: 85)

File: tests/CSharpAcdc.IntegrationTests/Helpers/FakeApiServer.cs, lines 117-143

The WireMock scenario is hardcoded as "Auth-Retry". If a test calls RespondWith401ThenSuccess for multiple different paths (e.g., /path-a and /path-b), they share the same scenario state. The first 401 on /path-a transitions the state to "Authenticated", and then /path-b would never return 401 -- it would skip straight to 200.

Currently no test calls this method for multiple paths, but this is a latent bug that will confuse future test authors.

Suggested fix: Include the path in the scenario name:

.InScenario($"Auth-Retry-{path}")

4. UserIsolation test uses separate DI containers, which gives each client its own FusionCache instance (Confidence: 83)

File: tests/CSharpAcdc.IntegrationTests/CacheIntegrationTests.cs, lines 134-178

The test builds client1 and client2 via separate BuildClient calls, which each create a new ServiceCollection and ServiceProvider. This means each client has its own independent FusionCache instance. The test passes because the caches are entirely separate containers -- not because the CacheKeyStrategy.UserIsolated logic correctly partitions entries within a shared cache.

To properly test user isolation, both users should share the same FusionCache instance (same DI container) but have different tokens. This would validate that the CacheKeyBuilder produces different keys for user-1 vs user-2 within the same cache.

Suggested fix: Register both clients in the same ServiceCollection (using different keyed names but the same FusionCache instance), or register a single client and swap the token provider between calls.


5. BuilderConfiguration_IsImmutable test has an async signature but no await (Confidence: 80)

File: tests/CSharpAcdc.IntegrationTests/BuilderReusabilityTests.cs, lines 47-66

The test method is async Task but contains no await expressions. This will generate a compiler warning (CS1998). Additionally, the test only checks that two clients have different timeouts -- it does not actually prove immutability (e.g., that calling WithTimeout on a builder does not mutate the original builder). The test creates two completely separate builders, so it is more of a "two different configurations produce different results" test than an immutability test.


Minor Observations (below threshold, noted for awareness)

  • The ETag header matching in ConfigureGetWithETag (line 56 of FakeApiServer.cs) has deeply nested escape sequences ($"\"\\\"" + etag + "\\\"\"") that are hard to read. The Copilot reviewer already flagged this. Confirm the matcher actually works correctly with the escaped quotes the CacheHandler sends.

  • ProactiveRefresh_RefreshesTokenBeforeExpiry uses Assert.True(_oauth.GetCallCount("/token") >= 1) after a 500ms Task.Delay for a fire-and-forget operation. This is inherently timing-sensitive. Under CI load, 500ms may not be enough. Consider using a polling/retry assertion pattern (e.g., loop with short sleeps up to a timeout) for more robust CI behavior.

  • The FullPipeline_GetRequest_FlowsThroughAllHandlers test verifies the response is correct, but does not actually validate handler ordering. The test name suggests it validates the pipeline order (Logging -> Error -> Cancellation -> Auth -> Cache -> Dedup), but there is no assertion about ordering. This matches the task spec wording though, so it may be intentional.


Summary

The test suite provides good coverage of the happy paths and error classification. The two critical issues (#1 and #2) are about tests that claim to verify concurrency and race conditions but do not actually exercise those scenarios -- which is particularly important given CLAUDE.md's emphasis that "thread safety is the #1 concern." Fixing those two tests would significantly strengthen confidence in the auth handler's concurrent refresh queue behavior.

jhosm and others added 2 commits February 14, 2026 19:19
1. ConcurrentRefreshQueue test now sends 8 concurrent requests via
   Task.WhenAll with a slow token endpoint, verifying the leader/follower
   Interlocked.CompareExchange pattern coalesces into 1 refresh call.

2. LogoutDuringRefresh test now triggers a 401 causing a slow (2s) token
   refresh, then calls LogoutAsync concurrently to test graceful handling.

3. FakeApiServer.RespondWith401ThenSuccess uses unique scenario names
   (counter + path) to avoid WireMock collisions across multiple calls.

4. UserIsolation test now uses a single DI container (shared FusionCache)
   and swaps token provider tokens to validate CacheKeyStrategy.UserIsolated
   within the same cache instance.

5. BuilderConfiguration_IsImmutable removes async keyword (no awaits).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jhosm jhosm merged commit 7baa814 into main Feb 14, 2026
1 check passed
@jhosm jhosm deleted the p8/add-integration-tests branch February 14, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant