From cb2b4bebe366b140c72d31f72af04080053e2aa4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 26 Sep 2026 17:29:03 +0200 Subject: [PATCH 1/2] test: strengthen assertions and remove timing assumptions --- .../PostHogAIExtensionsTests.cs | 4 + .../PostHogOpenAIHandlerTests.cs | 53 ++++++--- .../HttpContextFeatureFlagCacheTests.cs | 36 ++++-- .../PostHogRequestContextMiddlewareTests.cs | 21 +++- tests/UnitTests/CaptureExtensionsTests.cs | 2 + tests/UnitTests/Features/ETagSupportTests.cs | 5 +- .../Features/FallbackFeatureFlagCacheTests.cs | 13 ++- .../Features/FeatureFlagCacheKeyTests.cs | 16 +++ .../Features/FeatureFlagEvaluationsTests.cs | 21 +++- tests/UnitTests/Features/FeatureFlagsTests.cs | 66 ++++++++--- .../UnitTests/Features/LocalEvaluatorTests.cs | 11 +- .../Features/LocalFeatureFlagsLoaderTests.cs | 33 +++--- .../Features/MemoryFeatureFlagCacheTests.cs | 32 +++--- tests/UnitTests/Features/RemoteConfigTests.cs | 7 ++ .../Json/JsonSerializerHelperTests.cs | 26 ++++- .../Json/PropertyFilterValueTests.cs | 7 +- .../Library/AsyncBatchHandlerTests.cs | 36 ++++-- .../Library/HttpClientExtensionsTests.cs | 25 +++-- tests/UnitTests/Library/PeriodicTimerTests.cs | 2 +- .../Library/RelativeDateParserTests.cs | 25 +++-- tests/UnitTests/PostHogClientTests.cs | 106 +++++++++++------- tests/UnitTests/PostHogContextTests.cs | 39 ++++++- tests/UnitTests/PostHogSdkTests.cs | 1 + 23 files changed, 419 insertions(+), 168 deletions(-) diff --git a/tests/PostHog.AI.Tests/PostHogAIExtensionsTests.cs b/tests/PostHog.AI.Tests/PostHogAIExtensionsTests.cs index 18c72c5d..530347f8 100644 --- a/tests/PostHog.AI.Tests/PostHogAIExtensionsTests.cs +++ b/tests/PostHog.AI.Tests/PostHogAIExtensionsTests.cs @@ -38,5 +38,9 @@ public void AddPostHogOpenAIClientSucceedsWhenPostHogIsRegistered() // Should not throw var builder = services.AddPostHogOpenAIClient("sk-test-key"); Assert.NotNull(builder); + using var provider = services.BuildServiceProvider(); + Assert.NotNull(provider.GetRequiredService()); + using var handler = provider.GetRequiredService(); + Assert.NotNull(handler); } } diff --git a/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs b/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs index b88d2699..53d2a442 100644 --- a/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs +++ b/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs @@ -466,8 +466,7 @@ public async Task SendAsyncIncludesInputAndOutputChoicesWhenPrivacyModeIsFalseSi Arg.Any(), PostHogAIFieldNames.Generation, Arg.Is>(props => - props.ContainsKey(PostHogAIFieldNames.Input) - && props.ContainsKey(PostHogAIFieldNames.OutputChoices) + HasExpectedMessagePayloads(props) ), null, false, @@ -690,21 +689,23 @@ await writer.WriteAsync( HttpResponseMessage? response = null; try { - using (PostHogAIContext.BeginScope(privacyMode: true)) + using (PostHogAIContext.BeginScope(distinctId: "request-user", traceId: "request-trace", privacyMode: true)) { - response = await _client.PostAsync( - new Uri("/v1/chat/completions", UriKind.Relative), - requestContent - ); - + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/chat/completions", UriKind.Relative)) + { + Content = requestContent + }; + response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.True(response.IsSuccessStatusCode); + Assert.Empty(_postHogClient.ReceivedCalls()); } - // Stream consumed outside the scope — privacy mode must still apply - var resultStream = await response.Content.ReadAsStreamAsync(); - using (var reader = new StreamReader(resultStream)) + using (PostHogAIContext.BeginScope(distinctId: "other-user", traceId: "other-trace", privacyMode: false)) { - await reader.ReadToEndAsync(); + var resultStream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(resultStream); + Assert.Equal(Encoding.UTF8.GetString(sseStream.ToArray()), await reader.ReadToEndAsync()); + Assert.Empty(_postHogClient.ReceivedCalls()); } } finally @@ -723,11 +724,13 @@ await writer.WriteAsync( && !props.ContainsKey(PostHogAIFieldNames.OutputChoices) && (int)props[PostHogAIFieldNames.InputTokens] == 10 && (int)props[PostHogAIFieldNames.OutputTokens] == 5 + && (string)props[PostHogAIFieldNames.TraceId] == "request-trace" ), null, false, Arg.Any() ); + Assert.Equal("request-user", Assert.Single(_postHogClient.ReceivedCalls()).GetArguments()[0]); } [Fact] @@ -794,8 +797,7 @@ public async Task SendAsyncIncludesInputAndOutputChoicesWhenPrivacyModeIsNullSim Arg.Any(), PostHogAIFieldNames.Generation, Arg.Is>(props => - props.ContainsKey(PostHogAIFieldNames.Input) - && props.ContainsKey(PostHogAIFieldNames.OutputChoices) + HasExpectedMessagePayloads(props) ), null, false, @@ -803,6 +805,14 @@ public async Task SendAsyncIncludesInputAndOutputChoicesWhenPrivacyModeIsNullSim ); } + static bool HasExpectedMessagePayloads(Dictionary properties) + => JsonNode.DeepEquals( + JsonNode.Parse("""[{"role":"user","content":"Hello"}]"""), + properties[PostHogAIFieldNames.Input] as JsonNode) + && JsonNode.DeepEquals( + JsonNode.Parse("""[{"index":0,"message":{"role":"assistant","content":"Hi there!"},"finish_reason":"stop"}]"""), + properties[PostHogAIFieldNames.OutputChoices] as JsonNode); + [Fact] public async Task SendAsyncCapturesErrorEventOnNetworkException() { @@ -933,13 +943,18 @@ public async Task SendAsyncHandlesGracefullyOnMalformedJsonResponse() Assert.True(response.IsSuccessStatusCode); + Assert.Equal("this is not json {{{{", await response.Content.ReadAsStringAsync()); + // Event should still be captured (with whatever properties could be extracted) _postHogClient .Received(1) .Capture( Arg.Any(), - Arg.Any(), - Arg.Any>(), + PostHogAIFieldNames.Generation, + Arg.Is>(props => + (string)props[PostHogAIFieldNames.Model] == "gpt-4" + && (string)props[PostHogAIFieldNames.Provider] == "openai" + && (int)props[PostHogAIFieldNames.HttpStatus] == 200), null, false, Arg.Any() @@ -1017,7 +1032,8 @@ public async Task SendAsyncPassesGroupsToCaptureWhenContextHasGroups() Arg.Any(), PostHogAIFieldNames.Generation, Arg.Any>(), - Arg.Is(g => g != null && g.Count == 1), + Arg.Is(g => g != null && g.Count == 1 + && g.Single().GroupType == "company" && g.Single().GroupKey == "acme-corp"), false, Arg.Any() ); @@ -1078,6 +1094,7 @@ public async Task SendAsyncContextPropertiesOverrideEventProperties() properties: new Dictionary { { "custom_prop", "custom_value" }, + { PostHogAIFieldNames.Model, "context-model" }, } ) ) @@ -1098,6 +1115,8 @@ public async Task SendAsyncContextPropertiesOverrideEventProperties() PostHogAIFieldNames.Generation, Arg.Is>(props => (string)props["custom_prop"] == "custom_value" + && (string)props[PostHogAIFieldNames.Model] == "context-model" + && (int)props[PostHogAIFieldNames.InputTokens] == 9 ), null, false, diff --git a/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs b/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs index c65e24e1..e7938e05 100644 --- a/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs +++ b/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs @@ -86,12 +86,17 @@ public async Task DoesNotCacheIfHttpContextIsNull() } }; - var result = await cache.GetAndCacheFlagsAsync( - distinctId, - (_, _) => Task.FromResult(flagsResult), - CancellationToken.None); + var secondFlagsResult = new FlagsResult { RequestId = "second-fetch" }; + var fetchCount = 0; + Task Fetch(string _, CancellationToken token) + => Task.FromResult(++fetchCount == 1 ? flagsResult : secondFlagsResult); - Assert.Equal(flagsResult, result); + var result = await cache.GetAndCacheFlagsAsync(distinctId, Fetch, CancellationToken.None); + var secondResult = await cache.GetAndCacheFlagsAsync(distinctId, Fetch, CancellationToken.None); + + Assert.Equal(2, fetchCount); + Assert.Same(flagsResult, result); + Assert.Same(secondFlagsResult, secondResult); } @@ -231,7 +236,8 @@ public async Task ReturnsCachedFlagsWhenPersonPropertiesMatch() var cache = new HttpContextFeatureFlagCache(httpContextAccessor); var distinctId = "user123"; - var personProperties = new Dictionary { ["email"] = "test@example.com" }; + var personProperties = new Dictionary { ["email"] = "test@example.com", ["plan"] = "pro" }; + var equalProperties = new Dictionary { ["plan"] = "pro", ["email"] = "test@example.com" }; var flags = new FlagsResult { @@ -249,7 +255,7 @@ public async Task ReturnsCachedFlagsWhenPersonPropertiesMatch() }; var result1 = await cache.GetAndCacheFlagsAsync(distinctId, personProperties, null, fetcher, CancellationToken.None); - var result2 = await cache.GetAndCacheFlagsAsync(distinctId, personProperties, null, fetcher, CancellationToken.None); + var result2 = await cache.GetAndCacheFlagsAsync(distinctId, equalProperties, null, fetcher, CancellationToken.None); Assert.Equal(1, fetchCount); // Should only fetch once Assert.Same(result1, result2); @@ -457,11 +463,19 @@ public async Task DoesNotCacheIfHttpContextIsNull() { "feature1", new FeatureFlag { Key = "feature1", IsEnabled = true } } }; - var result = await cache.GetAndCacheFeatureFlagsAsync( - distinctId, - _ => Task.FromResult((IReadOnlyDictionary)featureFlags), - CancellationToken.None); + var secondFlags = new Dictionary + { + ["feature1"] = new FeatureFlag { Key = "feature1", IsEnabled = false } + }; + var fetchCount = 0; + Task> Fetch(CancellationToken _) + => Task.FromResult>(++fetchCount == 1 ? featureFlags : secondFlags); + + var result = await cache.GetAndCacheFeatureFlagsAsync(distinctId, Fetch, CancellationToken.None); + var secondResult = await cache.GetAndCacheFeatureFlagsAsync(distinctId, Fetch, CancellationToken.None); + Assert.Equal(2, fetchCount); Assert.Equal(featureFlags, result); + Assert.Equal(secondFlags, secondResult); } } \ No newline at end of file diff --git a/tests/UnitTests.AspNetCore/PostHogRequestContextMiddlewareTests.cs b/tests/UnitTests.AspNetCore/PostHogRequestContextMiddlewareTests.cs index 7773b54e..1c23b180 100644 --- a/tests/UnitTests.AspNetCore/PostHogRequestContextMiddlewareTests.cs +++ b/tests/UnitTests.AspNetCore/PostHogRequestContextMiddlewareTests.cs @@ -260,11 +260,17 @@ public async Task ConcurrentRequestsDoNotLeakRequestContext() var client = container.Activate(); var results = new Dictionary(); var gate = new object(); + var bothEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var enteredCount = 0; var middleware = CreateMiddleware( async context => { - await Task.Delay(25); + if (Interlocked.Increment(ref enteredCount) == 2) + { + bothEntered.SetResult(true); + } + await bothEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); lock (gate) { results[context.Request.Path.Value ?? string.Empty] = ( @@ -388,13 +394,22 @@ public async Task PreservesOriginalExceptionWhenExceptionCaptureThrows() Arg.Any()) .Returns(_ => throw new InvalidOperationException("capture failed")); + var original = new NotSupportedException("original"); var middleware = CreateMiddleware( - _ => throw new NotSupportedException("original"), + _ => throw original, postHog, options => options.CaptureExceptions = true); var exception = await Assert.ThrowsAsync(() => middleware.InvokeAsync(CreateHttpContext())); - Assert.Equal("original", exception.Message); + Assert.Same(original, exception); + postHog.Received(1).CaptureException( + original, + Arg.Any(), + Arg.Is?>(properties => properties != null + && (int)properties["$response_status_code"] == 500), + Arg.Any(), + Arg.Any(), + Arg.Any()); } static PostHogRequestContextMiddleware CreateMiddleware( diff --git a/tests/UnitTests/CaptureExtensionsTests.cs b/tests/UnitTests/CaptureExtensionsTests.cs index 4ed58f8a..c9d36a50 100644 --- a/tests/UnitTests/CaptureExtensionsTests.cs +++ b/tests/UnitTests/CaptureExtensionsTests.cs @@ -74,6 +74,7 @@ public void CaptureSurveyResponsesDoesNotMutateProvidedProperties() "survey sent", Arg.Is>(captured => !ReferenceEquals(captured, properties) + && (string)captured["source"] == "test" && (string)captured["$survey_id"] == "survey-id" && (string)captured["$survey_response"] == "first" && (string)captured["survey_response_1"] == "second"), @@ -88,6 +89,7 @@ static bool HasCopiedPersonProperties( Dictionary personPropertiesToSet, Dictionary personPropertiesToSetOnce) => !ReferenceEquals(captured, properties) + && (string)captured["source"] == "test" && captured["$set"] is Dictionary set && !ReferenceEquals(set, personPropertiesToSet) && (string)set["name"] == "Max" diff --git a/tests/UnitTests/Features/ETagSupportTests.cs b/tests/UnitTests/Features/ETagSupportTests.cs index 582e0664..0e45406f 100644 --- a/tests/UnitTests/Features/ETagSupportTests.cs +++ b/tests/UnitTests/Features/ETagSupportTests.cs @@ -86,7 +86,7 @@ public async Task NotModifiedResponseReturnsCachedFlags() "\"etag-123\""); // Second request returns 304 Not Modified - container.FakeHttpMessageHandler.AddLocalEvaluationNotModifiedResponse("\"etag-123\""); + var notModifiedHandler = container.FakeHttpMessageHandler.AddLocalEvaluationNotModifiedResponse("\"etag-123\""); // Need batch response for $feature_flag_called event container.FakeHttpMessageHandler.AddBatchResponse(); @@ -99,6 +99,9 @@ public async Task NotModifiedResponseReturnsCachedFlags() // Second load gets 304 - should still have flags from cache await client.LoadFeatureFlagsAsync(CancellationToken.None); + var request = Assert.Single(notModifiedHandler.ReceivedRequests); + Assert.Contains(request.Headers.IfNoneMatch, etag => etag.Tag == "\"etag-123\""); + // Verify flags still work from cached evaluator var result = await client.IsFeatureEnabledAsync("test-flag", "user-123"); Assert.True(result); diff --git a/tests/UnitTests/Features/FallbackFeatureFlagCacheTests.cs b/tests/UnitTests/Features/FallbackFeatureFlagCacheTests.cs index 04badf62..ec0c17eb 100644 --- a/tests/UnitTests/Features/FallbackFeatureFlagCacheTests.cs +++ b/tests/UnitTests/Features/FallbackFeatureFlagCacheTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Time.Testing; using PostHog; using PostHog.Features; @@ -8,9 +9,9 @@ public class TheGetAndCacheFeatureFlagsAsyncMethod [Fact] public async Task ReturnsItemInPrimaryCache() { - var timeProvider = TimeProvider.System; - var primaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); - var secondaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); + var timeProvider = new FakeTimeProvider(); + using var primaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); + using var secondaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); var cache = new FallbackFeatureFlagCache(primaryCache, secondaryCache); var distinctId = "test-distinct-id"; @@ -30,9 +31,9 @@ public async Task ReturnsItemInPrimaryCache() [Fact] public async Task ReturnsItemInSecondaryCache() { - var timeProvider = TimeProvider.System; - var primaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); - var secondaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); + var timeProvider = new FakeTimeProvider(); + using var primaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); + using var secondaryCache = new MemoryFeatureFlagCache(timeProvider, 10, 0.2); var cache = new FallbackFeatureFlagCache(primaryCache, secondaryCache); var distinctId = "test-distinct-id"; diff --git a/tests/UnitTests/Features/FeatureFlagCacheKeyTests.cs b/tests/UnitTests/Features/FeatureFlagCacheKeyTests.cs index be06dfe3..920034b8 100644 --- a/tests/UnitTests/Features/FeatureFlagCacheKeyTests.cs +++ b/tests/UnitTests/Features/FeatureFlagCacheKeyTests.cs @@ -287,6 +287,8 @@ public void HandlesNullValuesInProperties() var key2 = FeatureFlagCacheKey.Generate("user123", properties2, null); Assert.Equal(key1, key2); + properties2.Remove("middle_name"); + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", properties2, null)); } [Fact] @@ -305,6 +307,8 @@ public void HandlesComplexNestedProperties() var key2 = FeatureFlagCacheKey.Generate("user123", properties, null); Assert.Equal(key1, key2); + ((Dictionary)properties["metadata"]!)["version"] = 3; + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", properties, null)); } [Fact] @@ -348,6 +352,8 @@ public void GeneratesStableKeyWithMultipleGroupsEachWithProperties() var key2 = FeatureFlagCacheKey.Generate("user123", null, groups); Assert.Equal(key1, key2); + groups.Single(group => group.GroupType == "team")["size"] = 51; + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", null, groups)); } [Fact] @@ -367,6 +373,11 @@ public void HandlesDiversePropertyTypes() var key2 = FeatureFlagCacheKey.Generate("user123", properties, null); Assert.Equal(key1, key2); + properties["int"] = "42"; + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", properties, null)); + properties["int"] = 42; + properties["array"] = new[] { "vip", "stable" }; + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", properties, null)); } [Fact] @@ -396,6 +407,7 @@ public void HandlesWhitespaceDistinctId() var key2 = FeatureFlagCacheKey.Generate(" ", null, null); Assert.Equal(key1, key2); + Assert.Equal(" ", key1); } [Fact] @@ -436,6 +448,8 @@ public void HandlesLargeNumberOfProperties() Assert.Equal(key1, key2); Assert.NotEmpty(key1); + properties["prop99"] = "changed"; + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", properties, null)); } [Fact] @@ -452,6 +466,8 @@ public void HandlesLargeNumberOfGroups() Assert.Equal(key1, key2); Assert.NotEmpty(key1); + groups["group19"] = new Group("group19", "changed"); + Assert.NotEqual(key1, FeatureFlagCacheKey.Generate("user123", null, groups)); } [Fact] diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index a74fbc05..ac5a4999 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -33,11 +33,14 @@ public async Task NullOptionsAndOmittedFlagKeysReturnSnapshotFromOneFlagsRequest "flags": { "flag-a": { "key": "flag-a", - "metadata": {"id": 42, "version": 7}, + "enabled": true, + "metadata": {"id": 42, "version": 7, "payload": "{\"hello\":\"world\"}"}, "reason": {"description": "matched condition set 1"} }, "flag-b": { "key": "flag-b", + "enabled": true, + "variant": "variant-x", "metadata": {"id": 43, "version": 2}, "reason": {"description": "variant assignment"} } @@ -48,10 +51,15 @@ public async Task NullOptionsAndOmittedFlagKeysReturnSnapshotFromOneFlagsRequest """); var client = container.Activate(); + var extraRequest = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {}}"""); var options = passOptions ? new AllFeatureFlagsOptions() : null; var snapshot = await client.EvaluateFlagsAsync("user-1", options, CancellationToken.None); - Assert.Equal(2, snapshot.Keys.Count); + Assert.Equal(["flag-a", "flag-b"], snapshot.Keys.OrderBy(key => key, StringComparer.Ordinal)); + Assert.True(snapshot.IsEnabled("flag-a")); + Assert.Equal("variant-x", snapshot.GetFlag("flag-b")?.VariantKey); + Assert.Equal("world", snapshot.GetFlagPayload("flag-a")?.RootElement.GetProperty("hello").GetString()); + Assert.Empty(extraRequest.ReceivedRequests); Assert.Equal("the-request-id", snapshot.RequestId); Assert.Equal(1705862903000, snapshot.EvaluatedAt); Assert.Single(flagsHandler.ReceivedRequests); @@ -571,6 +579,7 @@ public async Task GetFlagPayloadDoesNotFireFeatureFlagCalledEvent() var payload = snapshot.GetFlagPayload("flag-a"); Assert.NotNull(payload); + Assert.Equal("hello", payload.RootElement.GetString()); await client.FlushAsync(); Assert.Empty(batchHandler.ReceivedRequests); @@ -832,7 +841,8 @@ public async Task DoesNotMakeAdditionalFlagsHttpRequest() { var container = new TestContainer(); var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); - container.FakeHttpMessageHandler.AddBatchResponse(); + var extraRequest = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); var client = container.Activate(); var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); @@ -840,6 +850,11 @@ public async Task DoesNotMakeAdditionalFlagsHttpRequest() await client.FlushAsync(); Assert.Single(flagsHandler.ReceivedRequests); + Assert.Empty(extraRequest.ReceivedRequests); + using var document = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var captured = Assert.Single(document.RootElement.GetProperty("batch").EnumerateArray()); + Assert.Equal("page_viewed", captured.GetProperty("event").GetString()); + Assert.True(captured.GetProperty("properties").GetProperty("$feature/flag-a").GetBoolean()); } [Fact] diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index f90b6240..1e232f02 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -572,12 +572,13 @@ public async Task CapturesFeatureFlagCalledEventWithoutEvaluatedAtWhenNotPresent { var container = new TestContainer(personalApiKey: "fake-personal-api-key"); var messageHandler = container.FakeHttpMessageHandler; - messageHandler.AddFlagsResponse( + messageHandler.AddResponse( + new Uri("https://us.i.posthog.com/flags/?v=2"), + HttpMethod.Post, """ { "featureFlags": {"flag-key": true}, "requestId": "the-request-id", - "evaluatedAt": 1705862903000, "featureFlagPayloads": {} } """ @@ -605,7 +606,6 @@ public async Task CapturesFeatureFlagCalledEventWithoutEvaluatedAtWhenNotPresent "locally_evaluated": false, "$feature/flag-key": true, "$feature_flag_request_id": "the-request-id", - "$feature_flag_evaluated_at": 1705862903000, "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", @@ -1929,19 +1929,22 @@ public async Task NeverReturnsNullDuringRegularEvaluation() var client = container.Activate(); // beta-feature resolves to False, so no matter the default, stays False - Assert.False(await client.GetFeatureFlagAsync("beta-feature", "some-distinct-id")); + var disabledLocalFlag = await client.GetFeatureFlagAsync("beta-feature", "some-distinct-id"); + Assert.NotNull(disabledLocalFlag); + Assert.False(disabledLocalFlag.IsEnabled); Assert.False(await client.IsFeatureEnabledAsync("beta-feature", "some-distinct-id")); Assert.Empty(requestHandler.ReceivedRequests); - // beta-feature2 falls back to decide, and whatever decide returns is the value - Assert.False(await client.GetFeatureFlagAsync("beta-feature2", "some-distinct-id")); + var missingRemoteFlag = await client.GetFeatureFlagAsync("beta-feature2", "some-distinct-id"); + Assert.NotNull(missingRemoteFlag); + Assert.False(missingRemoteFlag.IsEnabled); Assert.False(await client.IsFeatureEnabledAsync("beta-feature2", "some-distinct-id")); Assert.Single(requestHandler.ReceivedRequests); Assert.Single(secondRequestHandler.ReceivedRequests); } [Fact] // Ported from PostHog/posthog-python test_feature_flag_return_none_when_decide_errors_out - public async Task ReturnsNullWhenDecideThrowsException() + public async Task ReturnsDisabledFlagWhenRemoteEvaluationFails() { var container = new TestContainer(personalApiKey: "fake-personal-api-key"); var firstRequestHandler = @@ -1951,8 +1954,10 @@ public async Task ReturnsNullWhenDecideThrowsException() container.FakeHttpMessageHandler.AddLocalEvaluationResponse("""{"flags":[]}"""); var client = container.Activate(); - // beta-feature2 falls back to decide, which on error returns false - Assert.False(await client.GetFeatureFlagAsync("beta-feature2", "some-distinct-id")); + var disabledFlag = await client.GetFeatureFlagAsync("beta-feature2", "some-distinct-id"); + Assert.NotNull(disabledFlag); + Assert.Equal("beta-feature2", disabledFlag.Key); + Assert.False(disabledFlag.IsEnabled); Assert.False(await client.IsFeatureEnabledAsync("beta-feature2", "some-distinct-id")); Assert.Single(firstRequestHandler.ReceivedRequests); Assert.Single(secondRequestHandler.ReceivedRequests); @@ -2896,7 +2901,9 @@ public async Task ReturnsFalseWhenFlagDoesNotExist() var result = await client.GetFeatureFlagAsync("unknown-flag-key", "distinctId"); - Assert.False(result); + Assert.NotNull(result); + Assert.Equal("unknown-flag-key", result.Key); + Assert.False(result.IsEnabled); } [Fact] @@ -3242,12 +3249,18 @@ public async Task DoesNotCaptureFeatureFlagCalledEventWhenSendFeatureFlagsFalse( { var container = new TestContainer(); var messageHandler = container.FakeHttpMessageHandler; - messageHandler.AddRepeatedFlagsResponse( - count: 4, - responseBodyFunc: count => $$"""{"featureFlags": {"flag-key": "feature-value-{{count}}"} }"""); + var flagsHandler = messageHandler.AddFlagsResponse("""{"featureFlags":{"flag-key":"feature-value-0"}}"""); var captureRequestHandler = messageHandler.AddBatchResponse(); var posthog = container.Activate(); + var flag = await posthog.GetFeatureFlagAsync("flag-key", "a-distinct-id", + new FeatureFlagOptions { SendFeatureFlagEvents = false }); + Assert.NotNull(flag); + Assert.Equal("flag-key", flag.Key); + Assert.Equal("feature-value-0", flag.VariantKey); + Assert.True(flag.IsEnabled); + Assert.Single(flagsHandler.ReceivedRequests); + await posthog.FlushAsync(); Assert.Empty(captureRequestHandler.ReceivedRequests); } @@ -3903,13 +3916,15 @@ public async Task ComputesInactiveFlagsLocally() """ ); container.FakeTimeProvider.Advance(TimeSpan.FromMinutes(1)); - await Task.Delay(100); // Cede execution to thread that's loading the new flags. + var updatedFlags = await WaitForLocalFlagsAsync(client, "some-distinct-id", + flags => flags.TryGetValue("beta-feature", out var beta) && !beta.IsEnabled + && flags.TryGetValue("disabled-feature", out var enabled) && enabled.IsEnabled); Assert.Equal(new Dictionary { ["beta-feature"] = new() { Key = "beta-feature", IsEnabled = false }, ["disabled-feature"] = new() { Key = "disabled-feature", IsEnabled = true } - }, await client.GetAllFeatureFlagsAsync("some-distinct-id")); + }, updatedFlags); } [Fact] @@ -3963,15 +3978,30 @@ public async Task UpdatesFeatureFlagsOnTimer() """ ); container.FakeTimeProvider.Advance(TimeSpan.FromSeconds(31)); - await Task.Delay(100); // Cede execution to thread that's loading the new flags. - - var newResult = await client.GetAllFeatureFlagsAsync("distinct_id"); + var newResult = await WaitForLocalFlagsAsync(client, "distinct_id", flags => flags.ContainsKey("flag-key-2")); Assert.NotNull(newResult); var newFlag = Assert.Single(newResult.Values); Assert.Equal("flag-key-2", newFlag.Key); } + static async Task> WaitForLocalFlagsAsync( + PostHogClient client, string distinctId, Func, bool> isUpdated) + { + var timeout = System.Diagnostics.Stopwatch.StartNew(); + while (timeout.Elapsed < TimeSpan.FromSeconds(5)) + { + var flags = await client.GetAllFeatureFlagsAsync(distinctId, + new AllFeatureFlagsOptions { OnlyEvaluateLocally = true }); + if (isUpdated(flags)) + { + return flags; + } + await Task.Delay(1); + } + throw new TimeoutException("The timer did not publish updated local flags."); + } + [Fact] public async Task ReturnsEmptyDictionaryWhenPersonalApiKeyIncorrect() { diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index 40667774..a3749132 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -1680,9 +1680,12 @@ public void HandlesCircularDependenciesWithEmptyChain() var flags = CreateFlagsWithDependencies(new Dictionary { ["flag-a"] = flagA, + ["flag-b"] = CreateSimpleFlag("flag-b", active: true), + ["valid-flag"] = CreateFlagWithDependency("valid-flag", "flag-b", expectedValue: true, ["flag-b"]) }); var localEvaluator = new LocalEvaluator(flags); + Assert.True(localEvaluator.EvaluateFeatureFlag("valid-flag", "test-user", personProperties: new Dictionary()).Value); Assert.Throws(() => localEvaluator.EvaluateFeatureFlag( @@ -1762,16 +1765,18 @@ public void ReturnsFalseWhenEarlyDependencyInChainFails() [Fact] public void ThrowsInconclusiveMatchExceptionWhenDependencyChainFlagNotFound() { - var mainFlag = CreateFlagWithDependency("main-flag", "non-existent-flag", expectedValue: true, ["non-existent-flag"]); + var mainFlag = CreateFlagWithDependency("main-flag", "dependency-flag", expectedValue: true, ["missing-ancestor", "dependency-flag"]); var flags = CreateFlagsWithDependencies(new Dictionary { - ["main-flag"] = mainFlag + ["main-flag"] = mainFlag, + ["dependency-flag"] = CreateSimpleFlag("dependency-flag", active: true), + ["valid-flag"] = CreateFlagWithDependency("valid-flag", "dependency-flag", expectedValue: true, ["dependency-flag"]) }); var localEvaluator = new LocalEvaluator(flags); - // This should throw because the dependency chain references a non-existent flag + Assert.True(localEvaluator.EvaluateFeatureFlag("valid-flag", "test-user", personProperties: new Dictionary()).Value); Assert.Throws(() => localEvaluator.EvaluateFeatureFlag( key: "main-flag", diff --git a/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs b/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs index 3184cadb..b66deef6 100644 --- a/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs +++ b/tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs @@ -117,8 +117,8 @@ public class TheDisposeAsyncMethod public async Task CompletesGracefullyDuringInFlightPoll() { var container = new TestContainer("fake-personal-api-key"); - var pollStarted = new TaskCompletionSource(); - var pollCanProceed = new TaskCompletionSource(); + var pollStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pollCanProceed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // First response succeeds immediately (the initial load). container.FakeHttpMessageHandler.AddLocalEvaluationResponse(LocalEvaluationResponse); @@ -129,7 +129,7 @@ public async Task CompletesGracefullyDuringInFlightPoll() HttpMethod.Get, async () => { - pollStarted.SetResult(); + pollStarted.SetResult(true); await pollCanProceed.Task; return new HttpResponseMessage(HttpStatusCode.OK) { @@ -140,22 +140,25 @@ public async Task CompletesGracefullyDuringInFlightPoll() }; }); - var client = container.Activate(); - - // Initial load starts the polling loop and makes the first API call. - await client.LoadFeatureFlagsAsync(CancellationToken.None); + using var httpClient = new HttpClient(container.FakeHttpMessageHandler); + using var apiClient = container.Activate(httpClient); + await using var loader = container.Activate(apiClient); + await loader.RefreshAsync(CancellationToken.None); // Advance past the poll interval so the background poll fires. container.FakeTimeProvider.Advance(TimeSpan.FromSeconds(31)); - // Wait for the poll's API call to begin. - await pollStarted.Task; - - // Begin disposal while the poll is mid-flight. - var disposeTask = client.DisposeAsync().AsTask(); - - // Unblock the in-flight API call so the poll can finish. - pollCanProceed.SetResult(); + Task disposeTask; + try + { + Assert.Same(pollStarted.Task, await Task.WhenAny(pollStarted.Task, Task.Delay(TimeSpan.FromSeconds(5)))); + disposeTask = loader.DisposeAsync().AsTask(); + Assert.False(disposeTask.IsCompleted); + } + finally + { + pollCanProceed.SetResult(true); + } // Verify disposal completes without deadlock or exception. var timeout = TimeSpan.FromSeconds(5); diff --git a/tests/UnitTests/Features/MemoryFeatureFlagCacheTests.cs b/tests/UnitTests/Features/MemoryFeatureFlagCacheTests.cs index 6c8e4e1b..c2e7a040 100644 --- a/tests/UnitTests/Features/MemoryFeatureFlagCacheTests.cs +++ b/tests/UnitTests/Features/MemoryFeatureFlagCacheTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Time.Testing; using PostHog; using PostHog.Api; using PostHog.Features; @@ -9,8 +10,8 @@ public class TheGetAndCacheFeatureFlagsAsyncMethod [Fact] public async Task ReturnsCachedFlagsWhenFlagsAreCached() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-distinct-id"; var expectedFlags = new Dictionary { @@ -28,8 +29,8 @@ public async Task ReturnsCachedFlagsWhenFlagsAreCached() [Fact] public async Task FetchesAndCachesFlagsWhenFlagsAreNotCached() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-distinct-id"; var expectedFlags = new Dictionary { @@ -41,6 +42,9 @@ public async Task FetchesAndCachesFlagsWhenFlagsAreNotCached() _ => Task.FromResult>(expectedFlags), CancellationToken.None); Assert.Equal(expectedFlags, result); + var cached = await cache.GetAndCacheFeatureFlagsAsync(distinctId, + _ => throw new InvalidOperationException("Unexpected second fetch"), CancellationToken.None); + Assert.Equal(expectedFlags, cached); } } @@ -49,8 +53,8 @@ public class TheGetAndCacheFlagsAsyncMethodWithPropertiesAndGroups [Fact] public async Task ReturnsCachedFlagsWhenAllParametersMatch() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-user"; var personProperties = new Dictionary { ["email"] = "test@example.com" }; var groups = new GroupCollection { { "company", "acme" } }; @@ -83,8 +87,8 @@ public async Task ReturnsCachedFlagsWhenAllParametersMatch() [Fact] public async Task FetchesNewFlagsWhenPersonPropertiesDiffer() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-user"; var personProperties1 = new Dictionary { ["email"] = "test1@example.com" }; var personProperties2 = new Dictionary { ["email"] = "test2@example.com" }; @@ -122,8 +126,8 @@ public async Task FetchesNewFlagsWhenPersonPropertiesDiffer() [Fact] public async Task FetchesNewFlagsWhenGroupsDiffer() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-user"; var groups1 = new GroupCollection { { "company", "acme" } }; var groups2 = new GroupCollection { { "company", "initech" } }; @@ -161,8 +165,8 @@ public async Task FetchesNewFlagsWhenGroupsDiffer() [Fact] public async Task FetchesNewFlagsWhenGroupPropertiesDiffer() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-user"; var groups1 = new GroupCollection { @@ -206,8 +210,8 @@ public async Task FetchesNewFlagsWhenGroupPropertiesDiffer() [Fact] public async Task FetchesNewFlagsWhenNullVsNonNullProperties() { - var timeProvider = TimeProvider.System; - var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); + var timeProvider = new FakeTimeProvider(); + using var cache = new MemoryFeatureFlagCache(timeProvider, 100, 0.1); var distinctId = "test-user"; var personProperties = new Dictionary { ["email"] = "test@example.com" }; diff --git a/tests/UnitTests/Features/RemoteConfigTests.cs b/tests/UnitTests/Features/RemoteConfigTests.cs index 7d4526f0..8ed1a124 100644 --- a/tests/UnitTests/Features/RemoteConfigTests.cs +++ b/tests/UnitTests/Features/RemoteConfigTests.cs @@ -11,9 +11,16 @@ public async Task ReturnsNullForNonExistentKey() var container = new TestContainer("fake-personal-api-key"); var client = container.Activate(); + using var response = new HttpResponseMessage(System.Net.HttpStatusCode.NotFound); + var handler = container.FakeHttpMessageHandler.AddResponse( + new Uri("https://us.i.posthog.com/api/projects/@current/feature_flags/non-existent-key/remote_config?token=fake-project-token"), + HttpMethod.Get, + response); + var result = await client.GetRemoteConfigPayloadAsync("non-existent-key"); Assert.Null(result); + Assert.Single(handler.ReceivedRequests); } [Fact] diff --git a/tests/UnitTests/Json/JsonSerializerHelperTests.cs b/tests/UnitTests/Json/JsonSerializerHelperTests.cs index cdfa81c5..68f7c7a5 100644 --- a/tests/UnitTests/Json/JsonSerializerHelperTests.cs +++ b/tests/UnitTests/Json/JsonSerializerHelperTests.cs @@ -343,7 +343,7 @@ public async Task CanDeserializeLocalEvaluationApiResult() }) }; - // Verify the important parts - JSON deserialization is working correctly + Assert.Equal(expected, result); Assert.NotNull(result); Assert.Equal(4, result.Flags.Count); @@ -374,6 +374,27 @@ public async Task CanDeserializeAnotherLocalEvaluationApiResult() var result = await JsonSerializerHelper.DeserializeFromCamelCaseJsonStringAsync(json); Assert.NotNull(result); + Assert.Equal(5, result.Flags.Count); + var survey = result.Flags[0]; + Assert.Equal("survey-targeting-0da71edbdd-custom", survey.Key); + Assert.False(survey.Active); + var surveyGroup = Assert.Single(survey.Filters!.Groups!); + Assert.Equal("", surveyGroup.Variant); + Assert.Equal(2, surveyGroup.Properties!.Count); + Assert.All(surveyGroup.Properties, property => Assert.Equal(ComparisonOperator.IsNotSet, property.Operator)); + + Assert.Equal("{\"not\": \"a\", \"secret\": {\"payload\": 42}}", result.Flags[1].Filters!.Payloads!["true"]); + var multivariate = result.Flags[2].Filters!; + Assert.Equal(50, Assert.Single(multivariate.Groups!).RolloutPercentage); + Assert.Equal(["first-variant-key", "second-variant-key", "third-variant-key", "fourth-variant"], + multivariate.Multivariate!.Variants.Select(variant => variant.Key)); + Assert.All(multivariate.Multivariate.Variants, variant => Assert.Equal(25, variant.RolloutPercentage)); + Assert.Equal("42", multivariate.Payloads!["first-variant-key"]); + Assert.Equal("\"This is a test payload\"", result.Flags[3].Filters!.Payloads!["true"]); + Assert.Equal(3, result.Flags[4].Filters!.AggregationGroupTypeIndex); + Assert.Equal("project", result.GroupTypeMapping!["3"]); + var cohort = Assert.IsType(Assert.Single(result.Cohorts!["1"].Values)); + Assert.Equal(ComparisonOperator.IsSet, Assert.IsType(Assert.Single(cohort.Values)).Operator); } [Fact] @@ -683,7 +704,8 @@ public async Task CanDeserializeStringOrBoolWithFalse() Assert.NotNull(result); Assert.Equal("danaerys", result.TrueOrValue.StringValue); - Assert.False(result.TrueOrValue.Value); + Assert.False(result.AnotherTrueOrValue.Value); + Assert.True(result.AnotherTrueOrValue.IsValue); } public class ClassWithStringOr diff --git a/tests/UnitTests/Json/PropertyFilterValueTests.cs b/tests/UnitTests/Json/PropertyFilterValueTests.cs index 69fd8e0b..012f5bc8 100644 --- a/tests/UnitTests/Json/PropertyFilterValueTests.cs +++ b/tests/UnitTests/Json/PropertyFilterValueTests.cs @@ -340,8 +340,13 @@ public void NonBooleanJsonValuesHaveNullBooleanValue(string jsonValue) { var filterPropertyValue = PropertyFilterValue.Create(JsonDocument.Parse(jsonValue).RootElement); - if (filterPropertyValue != null) + if (jsonValue == "null") { + Assert.Null(filterPropertyValue); + } + else + { + Assert.NotNull(filterPropertyValue); Assert.Null(filterPropertyValue.BooleanValue); } } diff --git a/tests/UnitTests/Library/AsyncBatchHandlerTests.cs b/tests/UnitTests/Library/AsyncBatchHandlerTests.cs index 49488f9e..f194483c 100644 --- a/tests/UnitTests/Library/AsyncBatchHandlerTests.cs +++ b/tests/UnitTests/Library/AsyncBatchHandlerTests.cs @@ -37,7 +37,7 @@ public async Task CallsBatchHandlerWhenThresholdMet() } [Fact] - public async Task CallsBatchHandlerWithBatchContextWhenThresholdMet() + public async Task CallsBatchHandlerWithNewContextForEachFlush() { var options = new FakeOptions(new() { @@ -221,10 +221,15 @@ public async Task FlushAsyncWaitsForInProgressFlush() await CompleteWithin(flushStarted.Task, TimeSpan.FromSeconds(1)); var flushTask = batchHandler.FlushAsync(); - var completedEarly = await Task.WhenAny(flushTask, Task.Delay(TimeSpan.FromMilliseconds(100))); - Assert.NotSame(flushTask, completedEarly); - - flushCanProceed.SetResult(); + try + { + Assert.False(flushTask.IsCompleted); + Assert.Empty(items); + } + finally + { + flushCanProceed.SetResult(); + } await CompleteWithin(flushTask, TimeSpan.FromSeconds(1)); Assert.Equal([42], items); @@ -338,10 +343,11 @@ public async Task IgnoresEnqueuedItemAfterDispose() Assert.Equal([1], items); - batchHandler.Enqueue(Task.FromResult(2)); - batchHandler.Enqueue(Task.FromResult(3)); + Assert.False(batchHandler.Enqueue(Task.FromResult(2))); + Assert.False(batchHandler.Enqueue(Task.FromResult(3))); Assert.Equal(0, batchHandler.Count); + Assert.Equal([1], items); } static async Task CompleteWithin(Task task, TimeSpan timeout) @@ -385,7 +391,9 @@ public async Task FlushesBatchWhenDisposed() } var timeout = TimeSpan.FromSeconds(1); - var completedTask = await Task.WhenAny(handlerCompleteTask.Task, Task.Delay(timeout)); // Wait for the flush invoked by DisposeAsync to complete. + var completedTask = await Task.WhenAny(handlerCompleteTask.Task, Task.Delay(timeout)); + Assert.Same(handlerCompleteTask.Task, completedTask); + await handlerCompleteTask.Task; Assert.Equal([1, 2], items); } @@ -463,6 +471,7 @@ public async Task FlushesItemWhenDisposedDuringInFlightFlush() throw new TimeoutException("DisposeAsync did not complete within 5 seconds; possible deadlock."); } + await disposeTask; Assert.Equal([42], items); } @@ -474,7 +483,12 @@ public async Task HandlesExceptionsInFlushBatchAsync() FlushAt = 9, FlushInterval = TimeSpan.FromHours(3) }); - Func, Task> handlerFunc = batch => throw new HttpRequestException("Test exception"); + var attemptedItems = new List(); + Func, Task> handlerFunc = batch => + { + attemptedItems.AddRange(batch); + throw new HttpRequestException("Test exception"); + }; await using var batchHandler = new AsyncBatchHandler( handlerFunc, @@ -483,6 +497,8 @@ public async Task HandlesExceptionsInFlushBatchAsync() batchHandler.Enqueue(Task.FromResult(1)); batchHandler.Enqueue(Task.FromResult(2)); - // Test succeeds if no exception is thrown. + await batchHandler.DisposeAsync(); + + Assert.Equal([1, 2], attemptedItems); } } \ No newline at end of file diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index cc7e5def..71cbbfbd 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -588,7 +588,9 @@ public async Task RetriesOnConnectionResetThenSucceeds() CancellationToken.None); await handler.WaitForRequestCountAsync(1); +#if NET8_0_OR_GREATER Assert.Equal(1, handler.RequestCount); +#endif timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; @@ -686,6 +688,7 @@ public async Task UsesInitialRetryDelayAndDoublesForFeatureFlagRetries() await handler.WaitForRequestCountAsync(1); timeProvider.Advance(TimeSpan.FromMilliseconds(9)); #if NET8_0_OR_GREATER + // The netstandard build uses real Task.Delay, so fake time cannot hold a retry pending. Assert.Equal(1, handler.RequestCount); #endif timeProvider.Advance(TimeSpan.FromMilliseconds(1)); @@ -1178,7 +1181,9 @@ public async Task RetriesOnGatewayHttpStatusCodeThenSucceeds(HttpStatusCode stat CancellationToken.None); await handler.WaitForRequestCountAsync(1); +#if NET8_0_OR_GREATER Assert.Equal(1, handler.RequestCount); +#endif timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; @@ -1325,8 +1330,7 @@ public async Task CompressesRequestBodyWithGzip() using var reader = new StreamReader(gzipStream, Encoding.UTF8); var decompressedJson = await reader.ReadToEndAsync(); - Assert.Contains("test-event", decompressedJson, StringComparison.Ordinal); - Assert.Contains("api_key", decompressedJson, StringComparison.Ordinal); + JsonAssert.Equal("""{"api_key":"test","batch":[{"event":"test-event"}]}""", decompressedJson); } public static IEnumerable CompressionFailureExceptions() @@ -1384,23 +1388,23 @@ await httpClient.PostJsonWithRetryAsync( Assert.Empty(capturedContentEncoding ?? Enumerable.Empty()); Assert.NotNull(capturedBody); - Assert.Contains("test-event", capturedBody, StringComparison.Ordinal); - Assert.Contains("api_key", capturedBody, StringComparison.Ordinal); + JsonAssert.Equal("""{"api_key":"test","batch":[{"event":"test-event"}]}""", capturedBody); } [Fact] public async Task DoesNotCompressWhenCompressionDisabled() { + string? capturedBody = null; IEnumerable? capturedContentEncoding = null; - var handler = new LambdaHttpMessageHandler(request => + var handler = new LambdaHttpMessageHandler(async request => { capturedContentEncoding = request.Content?.Headers.ContentEncoding; - // Response disposal is handled by PostJsonWithRetryAsync via using declaration - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + capturedBody = await request.Content!.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"status\": 1}") - }); + }; }); using var httpClient = new HttpClient(handler); @@ -1419,7 +1423,10 @@ await httpClient.PostJsonWithRetryAsync( options, CancellationToken.None); - Assert.Empty(capturedContentEncoding ?? Enumerable.Empty()); + Assert.NotNull(capturedContentEncoding); + Assert.Empty(capturedContentEncoding); + Assert.NotNull(capturedBody); + JsonAssert.Equal("""{"api_key":"test","batch":[{"event":"test-event"}]}""", capturedBody); } } diff --git a/tests/UnitTests/Library/PeriodicTimerTests.cs b/tests/UnitTests/Library/PeriodicTimerTests.cs index 9664f72d..c152f588 100644 --- a/tests/UnitTests/Library/PeriodicTimerTests.cs +++ b/tests/UnitTests/Library/PeriodicTimerTests.cs @@ -92,7 +92,7 @@ public async Task TimerDoesNotTickAfterDispose() // Dispose the timer timer.Dispose(); - // Timer should return false immediately + _timeProvider.Advance(TimeSpan.FromMilliseconds(500)); Assert.False(await timer.WaitForNextTickAsync()); } } diff --git a/tests/UnitTests/Library/RelativeDateParserTests.cs b/tests/UnitTests/Library/RelativeDateParserTests.cs index 61162b36..99e10c50 100644 --- a/tests/UnitTests/Library/RelativeDateParserTests.cs +++ b/tests/UnitTests/Library/RelativeDateParserTests.cs @@ -6,21 +6,26 @@ namespace RelativeDateParserTests; public class TheParseMethod { [Theory] - [InlineData("-000h", "2024-01-22T22:15:50Z", "2024-01-21T16:15:49Z")] - [InlineData("-30h", "2024-01-22T22:15:50Z", "2024-01-21T16:15:49Z")] - [InlineData("-24d", "2024-01-22T22:15:50Z", "2023-12-29T22:15:49Z")] - [InlineData("-2w", "2024-01-22T22:15:50Z", "2024-01-08T22:15:49Z")] - [InlineData("-1m", "2024-01-22T22:15:50Z", "2023-12-22T22:15:49Z")] - [InlineData("-1y", "2024-01-22T22:15:50Z", "2023-01-22T22:15:49Z")] - public void CanCompareSpecifiedDateWithRelativeDate(string relativeDateString, string nowDate, string expectedBefore) + [InlineData("-000h", "2024-01-22T22:15:50Z", "2024-01-22T22:15:50Z")] + [InlineData("-30h", "2024-01-22T22:15:50Z", "2024-01-21T16:15:50Z")] + [InlineData("-24d", "2024-01-22T22:15:50Z", "2023-12-29T22:15:50Z")] + [InlineData("-2w", "2024-01-22T22:15:50Z", "2024-01-08T22:15:50Z")] + [InlineData("-1m", "2024-01-22T22:15:50Z", "2023-12-22T22:15:50Z")] + [InlineData("-1y", "2024-01-22T22:15:50Z", "2023-01-22T22:15:50Z")] + public void CanCompareSpecifiedDateWithRelativeDate(string relativeDateString, string nowDate, string expectedBoundary) { var now = DateTimeOffset.Parse(nowDate, CultureInfo.InvariantCulture); - var beforeDate = DateTimeOffset.Parse(expectedBefore, CultureInfo.InvariantCulture); + var boundary = DateTimeOffset.Parse(expectedBoundary, CultureInfo.InvariantCulture); var relativeDate = RelativeDate.Parse(relativeDateString); Assert.NotNull(relativeDate); - Assert.True(relativeDate.IsDateBefore(beforeDate, now)); + Assert.True(relativeDate.IsDateBefore(boundary.AddTicks(-1), now)); + Assert.False(relativeDate.IsDateBefore(boundary, now)); + Assert.False(relativeDate.IsDateBefore(boundary.AddTicks(1), now)); + Assert.True(relativeDate.IsDateBefore(boundary.AddTicks(-1).UtcDateTime, now)); + Assert.False(relativeDate.IsDateBefore(boundary.UtcDateTime, now)); + Assert.False(relativeDate.IsDateBefore(boundary.AddTicks(1).UtcDateTime, now)); } [Theory] @@ -31,6 +36,8 @@ public void CanCompareSpecifiedDateWithRelativeDate(string relativeDateString, s [InlineData("1s")] [InlineData("-u10_001h")] [InlineData("10_001h")] + [InlineData("-10000h")] + [InlineData("-999999999999999999999h")] [InlineData("bazinga")] [InlineData("")] public void ReturnsNullForBadFormats(string relativeDateString) diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 14b4183f..2c4bba45 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -460,7 +460,11 @@ public async Task BeforeSendCanModifyFullyEnrichedEventBeforeUpload() && capturedEvent.Properties.ContainsKey("$lib_version") && capturedEvent.Properties.ContainsKey("$is_server") && capturedEvent.Properties.TryGetValue("source", out var source) - && (string)source == "super"; + && (string)source == "super" + && capturedEvent.Properties.TryGetValue("$feature/flag1", out var flag) + && flag is true + && capturedEvent.Properties["$active_feature_flags"] is string[] activeFlags + && activeFlags.SequenceEqual(["flag1"]); capturedEvent.Properties.Remove("secret"); capturedEvent.Properties["before_send"] = true; return capturedEvent; @@ -469,8 +473,9 @@ public async Task BeforeSendCanModifyFullyEnrichedEventBeforeUpload() var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); var client = container.Activate(); var inputProperties = new Dictionary { ["secret"] = "remove-me" }; + container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags":{"flag1":true}}"""); - client.Capture("test-user", "before-send-event", inputProperties); + client.Capture("test-user", "before-send-event", inputProperties, groups: null, sendFeatureFlags: true); await client.FlushAsync(); Assert.Equal("remove-me", inputProperties["secret"]); @@ -1355,6 +1360,8 @@ public async Task CaptureExceptionOmitsStackTraceHiddenFrames() { var (_, requestHandler, client) = CreateClient(); var exception = CreateExceptionThroughHiddenMethod(); + Assert.Contains(new System.Diagnostics.StackTrace(exception, true).GetFrames(), + frame => frame.GetMethod()?.Name == nameof(ThrowFromHiddenMethod)); client.CaptureException(exception, "some-distinct-id"); await client.FlushAsync(); @@ -1362,6 +1369,8 @@ public async Task CaptureExceptionOmitsStackTraceHiddenFrames() var (_, _, properties) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: false)); var frames = GetStackFrames(GetFirstException(properties)); + Assert.Contains(frames, frame => + frame.GetProperty("function").GetString() == nameof(CreateExceptionThroughHiddenMethod)); Assert.DoesNotContain(frames, frame => frame.GetProperty("function").GetString() == nameof(ThrowFromHiddenMethod)); } @@ -1512,11 +1521,19 @@ public async Task CaptureExceptionWhenNoStackTrace() [Fact] public async Task CaptureExceptionCauseIOFailureEmptyContext() { - var (_, requestHandler, client) = CreateClient(); + var (container, requestHandler, client) = CreateClient(); var compiledThrower = await CreateDivideByZeroExceptionWithTempSourceFileAsync(); try { + client.CaptureException(compiledThrower.Exception, "some-distinct-id"); + await client.FlushAsync(); + var (_, _, readableProperties) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: false)); + var readableFrame = Assert.Single(GetStackFrames(GetFirstException(readableProperties)), + frame => frame.GetProperty("abs_path").GetString() == compiledThrower.SourcePath); + Assert.Equal("var _ = 1 / zero;", readableFrame.GetProperty("context_line").GetString()!.Trim()); + requestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + // Lock the source file exclusively so File.ReadAllLines(sourcePath) will throw IOException // and as result frames will not contain source code context. Use a temp file so parallel // target-framework test runs do not contend over this test source file. @@ -1525,6 +1542,7 @@ public async Task CaptureExceptionCauseIOFailureEmptyContext() FileMode.Open, FileAccess.Read, FileShare.None); + Assert.Throws(() => File.ReadAllLines(compiledThrower.SourcePath)); client.CaptureException(compiledThrower.Exception, "some-distinct-id"); await client.FlushAsync(); @@ -1536,21 +1554,16 @@ public async Task CaptureExceptionCauseIOFailureEmptyContext() Assert.True(File.Exists(compiledThrower.SourcePath)); Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); - var sourceFrame = frames.FirstOrDefault(f => + var sourceFrame = Assert.Single(frames, f => f.TryGetProperty("abs_path", out var absPath) && string.Equals(absPath.GetString(), compiledThrower.SourcePath, StringComparison.Ordinal)); - // In Release builds, stack frames may not include source file paths due - // to JIT optimizations, making this scenario impossible to reproduce. - if (sourceFrame.ValueKind is JsonValueKind.Undefined) - { - return; - } - + Assert.Equal("Boom", sourceFrame.GetProperty("function").GetString()); AssertContextEmpty(sourceFrame); } finally { + compiledThrower.LoadContext.Unload(); File.Delete(compiledThrower.SourcePath); } } @@ -1564,6 +1577,7 @@ public async Task CaptureExceptionCauseIOFailureEmptyContext() using System; public static class Thrower { + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] public static void Boom() { int zero = 0; @@ -1610,6 +1624,8 @@ public static void Boom() } catch (TargetInvocationException tie) when (tie.InnerException is DivideByZeroException ex) { + Assert.Contains(new System.Diagnostics.StackTrace(ex, true).GetFrames(), + frame => frame?.GetFileName() == sourcePath && frame.GetFileLineNumber() > 0); shouldDeleteSource = false; return (sourcePath, ex, assemblyLoadContext); } @@ -1666,6 +1682,7 @@ private static async IAsyncEnumerable ThrowFromAsyncIteratorAfterAwait() } #if NET8_0_OR_GREATER + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static InvalidOperationException CreateExceptionThroughHiddenMethod() { try @@ -1680,6 +1697,7 @@ private static InvalidOperationException CreateExceptionThroughHiddenMethod() } [System.Diagnostics.StackTraceHidden] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static void ThrowFromHiddenMethod() => throw new InvalidOperationException("Hidden exception"); #endif @@ -1691,29 +1709,30 @@ public async Task CaptureExceptionWithInvalidFilePathInStackFrame() { var (_, requestHandler, client) = CreateClient(); var compiledThrower = await CreateDivideByZeroExceptionWithTempSourceFileAsync(); - File.Delete(compiledThrower.SourcePath); - - client.CaptureException(compiledThrower.Exception, "some-distinct-id"); - await client.FlushAsync(); + try + { + File.Delete(compiledThrower.SourcePath); + Assert.False(File.Exists(compiledThrower.SourcePath)); - var (_, batchItem, props) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: true)); - var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); - var frames = GetStackFrames(divideByZeroException); - var sourceFrame = frames.FirstOrDefault(f => - f.TryGetProperty("abs_path", out var absPath) && - string.Equals(absPath.GetString(), compiledThrower.SourcePath, StringComparison.Ordinal)); + client.CaptureException(compiledThrower.Exception, "some-distinct-id"); + await client.FlushAsync(); - Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); + var (_, batchItem, props) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: true)); + var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); + var frames = GetStackFrames(divideByZeroException); + var sourceFrame = Assert.Single(frames, f => + f.TryGetProperty("abs_path", out var absPath) && + string.Equals(absPath.GetString(), compiledThrower.SourcePath, StringComparison.Ordinal)); - // Some runtime/build combinations do not include source file paths in stack frames. - // When source info is absent, this scenario cannot be reproduced. - if (sourceFrame.ValueKind is JsonValueKind.Undefined) + Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); + Assert.Equal(Path.GetFileName(compiledThrower.SourcePath), sourceFrame.GetProperty("filename").GetString()); + AssertContextEmpty(sourceFrame); + } + finally { - return; + compiledThrower.LoadContext.Unload(); + File.Delete(compiledThrower.SourcePath); } - - Assert.Equal(Path.GetFileName(compiledThrower.SourcePath), sourceFrame.GetProperty("filename").GetString()); - AssertContextEmpty(sourceFrame); } [Fact] @@ -1762,13 +1781,10 @@ public async Task CaptureExceptionWithCircularInnerReference() var fld = typeof(Exception).GetField("_innerException", BindingFlags.NonPublic | BindingFlags.Instance) ?? typeof(Exception).GetField("m_innerException", BindingFlags.NonPublic | BindingFlags.Instance); - if (fld is null) - { - // runtime doesn't expose the field - return; - } - + Assert.NotNull(fld); fld.SetValue(ex1, ex2); + Assert.Same(ex1, ex2.InnerException); + Assert.Same(ex2, ex1.InnerException); client.CaptureException(ex1, "some-distinct-id"); await client.FlushAsync(); @@ -1777,9 +1793,7 @@ public async Task CaptureExceptionWithCircularInnerReference() var (_, _, props) = ParseSingleEvent(received); var exceptionsList = GetExceptionList(props); - Assert.Equal(2, exceptionsList.Count); - Assert.Equal(ex1, ex2.InnerException); - Assert.Equal(ex2, ex1.InnerException); + Assert.Equal(["ex1", "ex2"], exceptionsList.Select(item => item.GetProperty("value").GetString())); } [Fact] @@ -2281,10 +2295,24 @@ public class TheLoadFeatureFlagsAsyncMethod public async Task LoadsFeatureFlagsSuccessfully() { var container = new TestContainer(personalApiKey: "fake-personal-api-key"); - container.FakeHttpMessageHandler.AddLocalEvaluationResponse("""{"flags": []}"""); + var initialResponse = container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """{"flags": [{"key":"reload-flag","active":true,"filters":{"groups":[{"properties":[],"rollout_percentage":100}]}}]}"""); var client = container.Activate(); + var options = new FeatureFlagOptions { OnlyEvaluateLocally = true, SendFeatureFlagEvents = false }; + + await client.LoadFeatureFlagsAsync(); + Assert.Single(initialResponse.ReceivedRequests); + var initialFlag = (await client.EvaluateFlagsAsync("user", options)).GetFlag("reload-flag"); + Assert.NotNull(initialFlag); + Assert.True(initialFlag.IsEnabled); + var refreshedResponse = container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """{"flags": [{"key":"reload-flag","active":false,"filters":{"groups":[{"properties":[],"rollout_percentage":100}]}}]}"""); await client.LoadFeatureFlagsAsync(); + Assert.Single(refreshedResponse.ReceivedRequests); + var refreshedFlag = (await client.EvaluateFlagsAsync("user", options)).GetFlag("reload-flag"); + Assert.NotNull(refreshedFlag); + Assert.False(refreshedFlag.IsEnabled); // Verify info log was recorded var infoLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Information); diff --git a/tests/UnitTests/PostHogContextTests.cs b/tests/UnitTests/PostHogContextTests.cs index 5ff98788..77370b8d 100644 --- a/tests/UnitTests/PostHogContextTests.cs +++ b/tests/UnitTests/PostHogContextTests.cs @@ -23,6 +23,10 @@ public void NestedScopesInheritUnlessFresh() Assert.Equal(true, PostHogContext.Current?.Properties["inner"]); } + Assert.Equal("outer-user", PostHogContext.Current?.DistinctId); + Assert.Equal("outer-session", PostHogContext.Current?.SessionId); + Assert.Equal(new Dictionary { ["outer"] = true }, PostHogContext.Current?.Properties); + using (PostHogContext.BeginScope(properties: new Dictionary { ["fresh"] = true }, fresh: true)) { Assert.Null(PostHogContext.Current?.DistinctId); @@ -30,6 +34,10 @@ public void NestedScopesInheritUnlessFresh() Assert.False(PostHogContext.Current?.Properties.ContainsKey("outer")); Assert.Equal(true, PostHogContext.Current?.Properties["fresh"]); } + + Assert.Equal("outer-user", PostHogContext.Current?.DistinctId); + Assert.Equal("outer-session", PostHogContext.Current?.SessionId); + Assert.Equal(new Dictionary { ["outer"] = true }, PostHogContext.Current?.Properties); } Assert.Null(PostHogContext.Current); @@ -188,9 +196,24 @@ public async Task ConcurrentAsyncScopesDoNotLeak() var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); var client = container.Activate(); - await Task.WhenAll( - CaptureInContextAsync("user-a", "session-a", "event-a"), - CaptureInContextAsync("user-b", "session-b", "event-b")); + var readyA = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readyB = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var taskA = CaptureInContextAsync("user-a", "session-a", "event-a", readyA); + var taskB = CaptureInContextAsync("user-b", "session-b", "event-b", readyB); + try + { + var bothReady = Task.WhenAll(readyA.Task, readyB.Task); + Assert.Same(bothReady, await Task.WhenAny(bothReady, Task.Delay(TimeSpan.FromSeconds(5)))); + await bothReady; + Assert.Null(PostHogContext.Current); + } + finally + { + release.SetResult(true); + await Task.WhenAll(taskA, taskB); + } + Assert.Null(PostHogContext.Current); await client.FlushAsync(); using var document = JsonDocument.Parse(requestHandler.GetReceivedRequestBody(indented: false)); @@ -198,17 +221,21 @@ await Task.WhenAll( .EnumerateArray() .ToDictionary(e => e.GetProperty("event").GetString()!); + Assert.Equal(2, events.Count); Assert.Equal("user-a", events["event-a"].GetProperty("distinct_id").GetString()); Assert.Equal("session-a", events["event-a"].GetProperty("properties").GetProperty("$session_id").GetString()); Assert.Equal("user-b", events["event-b"].GetProperty("distinct_id").GetString()); Assert.Equal("session-b", events["event-b"].GetProperty("properties").GetProperty("$session_id").GetString()); - async Task CaptureInContextAsync(string distinctId, string sessionId, string eventName) + async Task CaptureInContextAsync(string distinctId, string sessionId, string eventName, TaskCompletionSource ready) { using (PostHogContext.BeginScope(distinctId: distinctId, sessionId: sessionId, fresh: true)) { - await Task.Delay(10); - client.Capture(distinctId, eventName); + ready.SetResult(true); + await release.Task; + Assert.Equal(distinctId, PostHogContext.Current?.DistinctId); + Assert.Equal(sessionId, PostHogContext.Current?.SessionId); + client.Capture(null!, eventName); } } } diff --git a/tests/UnitTests/PostHogSdkTests.cs b/tests/UnitTests/PostHogSdkTests.cs index c6005781..51179980 100644 --- a/tests/UnitTests/PostHogSdkTests.cs +++ b/tests/UnitTests/PostHogSdkTests.cs @@ -104,6 +104,7 @@ public void InitCreatesAndStoresDefaultClient() { var client = PostHogSdk.Init(new PostHogOptions { ProjectToken = "test-token" }); + Assert.IsType(client); Assert.Same(client, PostHogSdk.DefaultClient); } From c50a5346824d483585e8cff864a9fa097b309a89 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sun, 27 Sep 2026 16:28:08 +0200 Subject: [PATCH 2/2] test: wait for retry timer registration before advancing time --- .../Library/HttpClientExtensionsTests.cs | 130 ++++++++---------- tests/UnitTests/Library/RetryTimeProvider.cs | 31 +++++ .../Library/RetryTimeProviderTests.cs | 73 ++++++++++ 3 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 tests/UnitTests/Library/RetryTimeProvider.cs create mode 100644 tests/UnitTests/Library/RetryTimeProviderTests.cs diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 71cbbfbd..5c2fd743 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -69,7 +69,7 @@ public async Task RetriesOnRetryableStatusCodeThenSucceeds(HttpStatusCode status handler.AddResponse(HttpStatusCode.OK, new { status = 1 }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); // Start the request var task = httpClient.PostJsonWithRetryAsync( @@ -79,8 +79,8 @@ public async Task RetriesOnRetryableStatusCodeThenSucceeds(HttpStatusCode status options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); // Advance time to trigger the retry timeProvider.Advance(TimeSpan.FromSeconds(1)); @@ -167,7 +167,7 @@ public async Task ThrowsAfterMaxRetriesWhenAllAttemptsFail() handler.AddResponse(HttpStatusCode.ServiceUnavailable, new { type = "error", detail = "Down" }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -177,9 +177,9 @@ public async Task ThrowsAfterMaxRetriesWhenAllAttemptsFail() CancellationToken.None); // Advance time for each retry attempt (1 initial + 3 retries) - for (var i = 1; i <= 4 && !task.IsCompleted; i++) + for (var i = 1; i <= 3; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromSeconds(1)); } @@ -200,7 +200,7 @@ public async Task RespectsRetryAfterDeltaHeader() handler.AddResponse(HttpStatusCode.OK, new { status = 1 }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -209,8 +209,8 @@ public async Task RespectsRetryAfterDeltaHeader() options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); #if NET8_0_OR_GREATER // Verify task is waiting for the Retry-After delay @@ -234,7 +234,7 @@ public async Task RespectsRetryAfterDateHeader() { Content = new StringContent("{\"type\": \"error\", \"detail\": \"rate limited\"}") }; - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); // Set Retry-After to a date 100ms in the future var retryAfterDate = timeProvider.GetUtcNow().AddMilliseconds(100); responseWithRetryAfter.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfterDate); @@ -250,8 +250,8 @@ public async Task RespectsRetryAfterDateHeader() options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); #if NET8_0_OR_GREATER // Verify task is waiting for the Retry-After delay @@ -292,11 +292,6 @@ public async Task RespectsRetryAfterDateInThePastByUsingZeroDelay() options, CancellationToken.None); - // Wait for first request to complete - await handler.WaitForRequestCountAsync(1); - - // With date in past, delay should be clamped to 0 - even minimal time advancement should trigger retry - timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; Assert.NotNull(result); @@ -314,7 +309,7 @@ public async Task ThrowsOperationCanceledExceptionWhenCancellationRequestedDurin using var httpClient = CreateHttpClient(handler); // Use a long delay so we can cancel during it var options = CreateOptions(maxRetries: 3, initialRetryDelay: TimeSpan.FromMinutes(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); using var cts = new CancellationTokenSource(); var task = httpClient.PostJsonWithRetryAsync( @@ -324,8 +319,8 @@ public async Task ThrowsOperationCanceledExceptionWhenCancellationRequestedDurin options, cts.Token); - // Wait for first request to complete (the one that returns 503) - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before canceling + await timeProvider.WaitForRetryAsync(handler, 1); // Cancel while waiting for retry delay await cts.CancelAsync(); @@ -350,7 +345,7 @@ public async Task CapsRetryDelayAtMaxRetryDelay() handler.AddResponse(HttpStatusCode.OK, new { status = 1 }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3, maxRetryDelay: TimeSpan.FromMilliseconds(50)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -359,8 +354,8 @@ public async Task CapsRetryDelayAtMaxRetryDelay() options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); #if NET8_0_OR_GREATER // Verify task is waiting (delay was capped, not skipped) @@ -383,7 +378,7 @@ public async Task RetriesOnHttpRequestException() handler.AddResponse(HttpStatusCode.OK, new { status = 1 }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -392,8 +387,8 @@ public async Task RetriesOnHttpRequestException() options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); timeProvider.Advance(TimeSpan.FromSeconds(1)); var result = await task; @@ -413,7 +408,7 @@ public async Task RetriesUntilSuccessAfterMultipleServiceUnavailableResponses() using var httpClient = CreateHttpClient(handler); // Use small delays for fast tests var options = CreateOptions(maxRetries: 3, initialRetryDelay: TimeSpan.FromMilliseconds(10)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -424,9 +419,9 @@ public async Task RetriesUntilSuccessAfterMultipleServiceUnavailableResponses() // Advance time for each retry with exponential backoff // Delays: 10ms, 20ms, 40ms (doubled each time) - for (var i = 1; i <= 4 && !task.IsCompleted; i++) + for (var i = 1; i <= 3; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(50)); } @@ -481,11 +476,6 @@ public async Task ClampsNegativeRetryAfterDeltaToZero() options, CancellationToken.None); - // Wait for first request to complete - await handler.WaitForRequestCountAsync(1); - - // With negative delta clamped to 0, minimal time advancement triggers retry - timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var result = await task; Assert.NotNull(result); @@ -502,7 +492,7 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeout() handler.AddResponse(HttpStatusCode.OK, new { status = 1 }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithRetryAsync( BatchUrl, @@ -511,8 +501,8 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeout() options, CancellationToken.None); - // Wait for first request to complete before advancing time - await handler.WaitForRequestCountAsync(1); + // Wait for retry timer registration before advancing fake time + await timeProvider.WaitForRetryAsync(handler, 1); timeProvider.Advance(TimeSpan.FromSeconds(1)); var result = await task; @@ -577,7 +567,7 @@ public async Task RetriesOnConnectionResetThenSucceeds() handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -587,7 +577,7 @@ public async Task RetriesOnConnectionResetThenSucceeds() new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - await handler.WaitForRequestCountAsync(1); + await timeProvider.WaitForRetryAsync(handler, 1); #if NET8_0_OR_GREATER Assert.Equal(1, handler.RequestCount); #endif @@ -675,7 +665,7 @@ public async Task UsesInitialRetryDelayAndDoublesForFeatureFlagRetries() handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 2, initialRetryDelay: TimeSpan.FromMilliseconds(10)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -685,14 +675,14 @@ public async Task UsesInitialRetryDelayAndDoublesForFeatureFlagRetries() new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - await handler.WaitForRequestCountAsync(1); + await timeProvider.WaitForRetryAsync(handler, 1); timeProvider.Advance(TimeSpan.FromMilliseconds(9)); #if NET8_0_OR_GREATER // The netstandard build uses real Task.Delay, so fake time cannot hold a retry pending. Assert.Equal(1, handler.RequestCount); #endif timeProvider.Advance(TimeSpan.FromMilliseconds(1)); - await handler.WaitForRequestCountAsync(2); + await timeProvider.WaitForRetryAsync(handler, 2); timeProvider.Advance(TimeSpan.FromMilliseconds(19)); #if NET8_0_OR_GREATER @@ -715,7 +705,7 @@ public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -725,9 +715,9 @@ public async Task RetriesUntilSuccessAfterMultipleConnectionResetErrors() new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - for (var i = 1; i <= 4 && !task.IsCompleted; i++) + for (var i = 1; i <= 3; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromSeconds(2)); } @@ -747,7 +737,7 @@ public async Task ThrowsAfterMaxRetriesWhenConnectionResetPersists() handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 3); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -757,9 +747,9 @@ public async Task ThrowsAfterMaxRetriesWhenConnectionResetPersists() new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - for (var i = 1; i <= 4 && !task.IsCompleted; i++) + for (var i = 1; i <= 3; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromSeconds(2)); } @@ -777,7 +767,7 @@ public async Task OpensCircuitAfterConsecutiveTransientFailuresAndFailsFast() } using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); var task = httpClient.PostJsonWithNetworkRetryAsync( @@ -788,9 +778,9 @@ public async Task OpensCircuitAfterConsecutiveTransientFailuresAndFailsFast() circuitBreaker, CancellationToken.None); - for (var i = 1; i <= 5 && !task.IsCompleted; i++) + for (var i = 1; i <= 4; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); } @@ -857,7 +847,7 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); var task = httpClient.PostJsonWithNetworkRetryAsync( @@ -868,9 +858,9 @@ public async Task HalfOpenProbeClosesCircuitAfterCooldown() circuitBreaker, CancellationToken.None); - for (var i = 1; i <= 5 && !task.IsCompleted; i++) + for (var i = 1; i <= 4; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); } @@ -919,7 +909,7 @@ public async Task HalfOpenProbeReopensCircuitWhenItFails() } using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); var task = httpClient.PostJsonWithNetworkRetryAsync( @@ -930,9 +920,9 @@ public async Task HalfOpenProbeReopensCircuitWhenItFails() circuitBreaker, CancellationToken.None); - for (var i = 1; i <= 5 && !task.IsCompleted; i++) + for (var i = 1; i <= 4; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); } @@ -971,7 +961,7 @@ public async Task HalfOpenProbeReopensCircuitWhenNonRetryableTransportFailureOcc handler.AddException(new HttpRequestException("Connection refused", new SocketException((int)SocketError.ConnectionRefused))); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); var task = httpClient.PostJsonWithNetworkRetryAsync( @@ -982,9 +972,9 @@ public async Task HalfOpenProbeReopensCircuitWhenNonRetryableTransportFailureOcc circuitBreaker, CancellationToken.None); - for (var i = 1; i <= 5 && !task.IsCompleted; i++) + for (var i = 1; i <= 4; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); } @@ -1023,7 +1013,7 @@ public async Task HalfOpenProbeReopensCircuitWhenUnexpectedExceptionOccurs() handler.AddException(new InvalidOperationException("Unexpected transport failure")); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 10, maxRetryDelay: TimeSpan.FromMilliseconds(1)); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var circuitBreaker = new FeatureFlagRequestCircuitBreaker(); var task = httpClient.PostJsonWithNetworkRetryAsync( @@ -1034,9 +1024,9 @@ public async Task HalfOpenProbeReopensCircuitWhenUnexpectedExceptionOccurs() circuitBreaker, CancellationToken.None); - for (var i = 1; i <= 5 && !task.IsCompleted; i++) + for (var i = 1; i <= 4; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromMilliseconds(1)); } @@ -1142,7 +1132,7 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -1152,7 +1142,7 @@ public async Task RetriesOnTaskCanceledExceptionFromTimeoutThenSucceeds() new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - await handler.WaitForRequestCountAsync(1); + await timeProvider.WaitForRetryAsync(handler, 1); timeProvider.Advance(TimeSpan.FromSeconds(1)); var result = await task; @@ -1170,7 +1160,7 @@ public async Task RetriesOnGatewayHttpStatusCodeThenSucceeds(HttpStatusCode stat handler.AddResponse(HttpStatusCode.OK, new { featureFlags = new { retry_flag = true } }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -1180,7 +1170,7 @@ public async Task RetriesOnGatewayHttpStatusCodeThenSucceeds(HttpStatusCode stat new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - await handler.WaitForRequestCountAsync(1); + await timeProvider.WaitForRetryAsync(handler, 1); #if NET8_0_OR_GREATER Assert.Equal(1, handler.RequestCount); #endif @@ -1230,7 +1220,7 @@ public async Task ThrowsAfterFeatureFlagRequestMaxRetriesWhenGatewayHttpStatusCo handler.AddResponse(statusCode, new { type = "error", detail = "server error" }); using var httpClient = CreateHttpClient(handler); var options = CreateOptions(maxRetries: 2); - var timeProvider = new FakeTimeProvider(); + var timeProvider = new RetryTimeProvider(); var task = httpClient.PostJsonWithNetworkRetryAsync( FlagsUrl, @@ -1240,9 +1230,9 @@ public async Task ThrowsAfterFeatureFlagRequestMaxRetriesWhenGatewayHttpStatusCo new FeatureFlagRequestCircuitBreaker(), CancellationToken.None); - for (var i = 1; i <= 3 && !task.IsCompleted; i++) + for (var i = 1; i <= 2; i++) { - await handler.WaitForRequestCountAsync(i); + await timeProvider.WaitForRetryAsync(handler, i); timeProvider.Advance(TimeSpan.FromSeconds(1)); } diff --git a/tests/UnitTests/Library/RetryTimeProvider.cs b/tests/UnitTests/Library/RetryTimeProvider.cs new file mode 100644 index 00000000..17ce2375 --- /dev/null +++ b/tests/UnitTests/Library/RetryTimeProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Time.Testing; +#if NET8_0_OR_GREATER +using System.Threading.Channels; +#endif + +namespace HttpClientExtensionsTests; + +class RetryTimeProvider : FakeTimeProvider +{ +#if NET8_0_OR_GREATER + readonly Channel _registeredTimers = Channel.CreateUnbounded(); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = base.CreateTimer(callback, state, dueTime, period); + _registeredTimers.Writer.TryWrite(true); + return timer; + } +#endif + + public async Task WaitForRetryAsync(FakeRetryHttpMessageHandler handler, int requestCount) + { +#if NET8_0_OR_GREATER + // The request counter is incremented before the retry delay is registered. + await _registeredTimers.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); +#else + // The netstandard build uses real Task.Delay rather than the injected clock. + await handler.WaitForRequestCountAsync(requestCount); +#endif + } +} diff --git a/tests/UnitTests/Library/RetryTimeProviderTests.cs b/tests/UnitTests/Library/RetryTimeProviderTests.cs new file mode 100644 index 00000000..34f09793 --- /dev/null +++ b/tests/UnitTests/Library/RetryTimeProviderTests.cs @@ -0,0 +1,73 @@ +#if NET8_0_OR_GREATER +using System.Net; +using System.Net.Sockets; +using PostHog; +using PostHog.Api; +using PostHog.Library; + +namespace HttpClientExtensionsTests; + +public class RetryTimeProviderTests +{ + [Fact] + public async Task WaitsForTimerRegistrationBeforeAdvancingClock() + { + var handler = new FakeRetryHttpMessageHandler(); + handler.AddException(new HttpRequestException("Connection reset", new SocketException((int)SocketError.ConnectionReset))); + handler.AddResponse(HttpStatusCode.OK, new { flags = new { } }); + using var httpClient = new HttpClient(handler); + var clock = new PausedTimeProvider(); + using var cancellation = new CancellationTokenSource(); + var request = Task.Run(() => httpClient.PostJsonWithNetworkRetryAsync( + new Uri("https://us.i.posthog.com/flags/?v=2"), + new { api_key = "test", distinct_id = "user-1" }, + clock, + new PostHogOptions { ProjectToken = "test", InitialRetryDelay = TimeSpan.FromMilliseconds(1) }, + new FeatureFlagRequestCircuitBreaker(), + cancellation.Token)); + try + { + await clock.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await handler.WaitForRequestCountAsync(1); + Assert.Equal(1, handler.RequestCount); + var readyToAdvance = clock.WaitForRetryAsync(handler, 1); + Assert.False(readyToAdvance.IsCompleted); + clock.Release.Set(); + await readyToAdvance; + clock.Advance(TimeSpan.FromMilliseconds(1)); + var result = await request.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotNull(result); + Assert.Equal(2, handler.RequestCount); + } + finally + { + clock.Release.Set(); + await cancellation.CancelAsync(); + try + { + await request.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) + { + } + clock.Release.Dispose(); + } + } + + sealed class PausedTimeProvider : RetryTimeProvider + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ManualResetEventSlim Release { get; } = new(); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + Entered.TrySetResult(true); + if (!Release.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("Timer-registration barrier was not released."); + } + return base.CreateTimer(callback, state, dueTime, period); + } + } +} +#endif