From 742921ce82f343fd54b93f1e9a9f017ae70e88f4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 26 Sep 2026 17:26:38 +0200 Subject: [PATCH] test: strengthen assertions and add runtime coverage measurement --- CONTRIBUTING.md | 12 +++ .../FeatureFlagModelTests.cs | 26 ++++-- .../FileStorageProviderTests.cs | 30 +++++- tests/PostHog.Unity.Tests/FlagCacheTests.cs | 63 +++++++++++-- .../FlagCalledTrackerTests.cs | 9 +- .../JsonSerializerTests.cs | 47 ++++++---- tests/PostHog.Unity.Tests/LruCacheTests.cs | 22 ++++- .../PostHog.Unity.Tests/NetworkClientTests.cs | 91 +++++++++++++++++-- .../PostHogFeatureFlagTests.cs | 4 +- tests/PostHog.Unity.Tests/PostHogJsonTests.cs | 8 +- .../PostHogSettingsTests.cs | 29 +++--- tests/PostHog.Unity.Tests/ReplayQueueTests.cs | 63 +++++++++++++ .../SessionManagerTests.cs | 53 +++++++++++ .../UnityExceptionIntegrationTests.cs | 12 ++- tests/coverage.runsettings | 16 ++++ 15 files changed, 412 insertions(+), 73 deletions(-) create mode 100644 tests/coverage.runsettings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3531a48..ee6b1ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,18 @@ Run tests with verbose output: bin/test --verbose ``` +### Code Coverage + +```bash +dotnet test tests/PostHog.Unity.Tests --settings tests/coverage.runsettings \ + --collect:"XPlat Code Coverage" --results-directory TestResults +``` + +The runtime sources are compiled directly into the test assembly, so the coverage +settings include that assembly while excluding test code and the generated test +entry point. Compare runs with the same settings and build configuration. +Coverage measures the .NET/Unity-stub build, not native Unity or WebGL execution. + ### Formatting Code Format all C# files: diff --git a/tests/PostHog.Unity.Tests/FeatureFlagModelTests.cs b/tests/PostHog.Unity.Tests/FeatureFlagModelTests.cs index b5fc3de..8740433 100644 --- a/tests/PostHog.Unity.Tests/FeatureFlagModelTests.cs +++ b/tests/PostHog.Unity.Tests/FeatureFlagModelTests.cs @@ -140,7 +140,9 @@ public void WithMetadata_IncludesMetadata() var dict = flag.ToDictionary(); - Assert.True(dict.ContainsKey("metadata")); + var metadata = Assert.IsType>(dict["metadata"]); + Assert.Equal(1, metadata["id"]); + Assert.Equal(2, metadata["version"]); } [Fact] @@ -153,7 +155,8 @@ public void WithReason_IncludesReason() var dict = flag.ToDictionary(); - Assert.True(dict.ContainsKey("reason")); + var reason = Assert.IsType>(dict["reason"]); + Assert.Equal("test", reason["description"]); } [Fact] @@ -255,7 +258,8 @@ public void WithPayload_IncludesPayload() var dict = metadata.ToDictionary(); - Assert.True(dict.ContainsKey("payload")); + var payload = Assert.IsType>(dict["payload"]); + Assert.Equal("value", payload["key"]); } [Fact] @@ -397,8 +401,7 @@ public void WithQuotaLimited_ParsesList() var result = FeatureFlagsResponse.FromDictionary(dict); Assert.NotNull(result.QuotaLimited); - Assert.Equal(2, result.QuotaLimited.Count); - Assert.Contains("flag1", result.QuotaLimited); + Assert.Equal(new[] { "flag1", "flag2" }, result.QuotaLimited); } [Fact] @@ -454,7 +457,8 @@ public void WithFeatureFlags_IncludesFlags() var dict = response.ToDictionary(); - Assert.True(dict.ContainsKey("featureFlags")); + var flags = Assert.IsType>(dict["featureFlags"]); + Assert.Equal(true, flags["flag"]); } [Fact] @@ -470,9 +474,9 @@ public void WithV4Flags_SerializesFlags() var dict = response.ToDictionary(); - Assert.True(dict.ContainsKey("flags")); - var flags = (Dictionary)dict["flags"]; - Assert.True(flags.ContainsKey("test")); + var flags = Assert.IsType>(dict["flags"]); + var flag = Assert.IsType>(flags["test"]); + Assert.Equal(true, flag["enabled"]); } [Fact] @@ -529,6 +533,10 @@ public void FromDictionaryAndToDictionary_PreservesData() var response = FeatureFlagsResponse.FromDictionary(original); var result = response.ToDictionary(); + var flags = Assert.IsType>(result["featureFlags"]); + Assert.Equal(2, flags.Count); + Assert.Equal(true, flags["flag1"]); + Assert.Equal("variant", flags["flag2"]); Assert.Equal(false, result["errorsWhileComputingFlags"]); Assert.Equal("test-request", result["requestId"]); Assert.Equal(1700000000L, result["evaluatedAt"]); diff --git a/tests/PostHog.Unity.Tests/FileStorageProviderTests.cs b/tests/PostHog.Unity.Tests/FileStorageProviderTests.cs index 521a7ab..ffa62a1 100644 --- a/tests/PostHog.Unity.Tests/FileStorageProviderTests.cs +++ b/tests/PostHog.Unity.Tests/FileStorageProviderTests.cs @@ -16,6 +16,7 @@ public FileStorageProviderTests() public void Dispose() { + _storage.FlushPendingWrites(); try { if (Directory.Exists(_testBasePath)) @@ -206,7 +207,9 @@ public void WaitsForPendingWrite_BeforeDeleting() // Assert - file should be deleted var filePath = Path.Combine(_testBasePath, "queue", $"{eventId}.json"); + _storage.FlushPendingWrites(); Assert.False(File.Exists(filePath)); + Assert.DoesNotContain(eventId, _storage.GetEventIds()); } [Fact] @@ -300,8 +303,9 @@ public void WaitsForPendingWrites_BeforeClearing() // Act - clear immediately after save _storage.Clear(); - // Assert - index should be empty + _storage.FlushPendingWrites(); Assert.Empty(_storage.GetEventIds()); + Assert.Empty(Directory.GetFiles(Path.Combine(_testBasePath, "queue"), "*.json")); } [Fact] @@ -417,7 +421,14 @@ public async Task ConcurrentSaves_DoNotCorruptIndex() // Assert var ids = _storage.GetEventIds(); - Assert.Equal(eventCount, ids.Count); + Assert.Equal( + Enumerable + .Range(0, eventCount) + .Select(i => $"concurrent-{i}") + .OrderBy(id => id), + ids.OrderBy(id => id) + ); + Assert.All(ids, id => Assert.Equal("{}", _storage.LoadEvent(id))); } [Fact] @@ -475,9 +486,18 @@ public async Task ConcurrentSavesAndDeletes_DoNotThrow() await Task.WhenAll(tasks); _storage.FlushPendingWrites(); - // Roughly half should remain (the odd-numbered ones) - var remaining = _storage.GetEventIds(); - Assert.True(remaining.Count >= eventCount / 2 - 5); // Allow some tolerance + var expected = Enumerable + .Range(0, eventCount) + .Where(i => i % 2 != 0) + .Select(i => $"save-delete-{i}") + .OrderBy(id => id) + .ToArray(); + Assert.Equal(expected, _storage.GetEventIds().OrderBy(id => id)); + var persistedIds = Directory + .GetFiles(Path.Combine(_testBasePath, "queue"), "*.json") + .Select(Path.GetFileNameWithoutExtension) + .OrderBy(id => id); + Assert.Equal(expected, persistedIds); } } } diff --git a/tests/PostHog.Unity.Tests/FlagCacheTests.cs b/tests/PostHog.Unity.Tests/FlagCacheTests.cs index 78d71d7..add10e1 100644 --- a/tests/PostHog.Unity.Tests/FlagCacheTests.cs +++ b/tests/PostHog.Unity.Tests/FlagCacheTests.cs @@ -162,9 +162,46 @@ public void WithNull_DoesNothing() Assert.False(cache.IsLoaded); } - // Note: QuotaLimited test removed because it triggers Unity Debug.LogWarning - // which isn't available in the xUnit test environment. The quota limited - // behavior is tested via integration tests in Unity. + [Fact] + public void QuotaLimited_ClearsFlagsAndPayloadsFromMemoryAndDisk() + { + var storage = new InMemoryStorageProvider(); + var cache = new FlagCache(storage); + cache.Update( + new FeatureFlagsResponse + { + FeatureFlags = new Dictionary { ["v3"] = true }, + FeatureFlagPayloads = new Dictionary { ["v3"] = "payload" }, + Flags = new Dictionary + { + ["v4"] = new FeatureFlag + { + Enabled = true, + Metadata = new FeatureFlagMetadata { Payload = "v4-payload" }, + }, + }, + } + ); + Assert.Equal(true, cache.GetFlag("v3")); + Assert.Equal(true, cache.GetFlag("v4")); + + cache.Update( + new FeatureFlagsResponse { QuotaLimited = new List { "feature_flags" } } + ); + var reloaded = new FlagCache(storage); + reloaded.LoadFromDisk(); + + foreach (var actual in new[] { cache, reloaded }) + { + Assert.True(actual.IsLoaded); + Assert.Empty(actual.GetAllFlagKeys()); + Assert.Null(actual.GetFlag("v3")); + Assert.Null(actual.GetFlag("v4")); + Assert.Null(actual.GetPayload("v3")); + Assert.Null(actual.GetPayload("v4")); + Assert.Null(actual.GetFlagDetails("v4")); + } + } [Fact] public void SavesToDisk() @@ -180,7 +217,14 @@ public void SavesToDisk() var savedData = storage.LoadState("feature_flags"); Assert.NotNull(savedData); - Assert.Contains("flag", savedData); + using var saved = System.Text.Json.JsonDocument.Parse(savedData); + Assert.True( + saved.RootElement.GetProperty("featureFlags").GetProperty("flag").GetBoolean() + ); + var reloaded = new FlagCache(storage); + reloaded.LoadFromDisk(); + Assert.True(reloaded.IsLoaded); + Assert.Equal(true, reloaded.GetFlag("flag")); } [Fact] @@ -374,8 +418,8 @@ public void WithV4Payload_ReturnsPayload() } ); - var payload = cache.GetPayload("flag"); - Assert.NotNull(payload); + var payload = Assert.IsType>(cache.GetPayload("flag")); + Assert.Equal("value", payload["key"]); } [Fact] @@ -566,7 +610,8 @@ public void ConcurrentReadsAndWrites_DoNotCorruptData() { cache.GetFlag("flag-1-1"); cache.GetPayload("flag-1-1"); - cache.GetAllFlagKeys(); + var keys = cache.GetAllFlagKeys(); + Assert.InRange(keys.Count, 0, 1); } }) ); @@ -575,6 +620,10 @@ public void ConcurrentReadsAndWrites_DoNotCorruptData() var ex = Record.Exception(() => Task.WaitAll(tasks.ToArray())); Assert.Null(ex); + Assert.True(cache.IsLoaded); + var finalKey = Assert.Single(cache.GetAllFlagKeys()); + Assert.Matches(@"^flag-[0-4]-99$", finalKey); + Assert.Equal(true, cache.GetFlag(finalKey)); } } } diff --git a/tests/PostHog.Unity.Tests/FlagCalledTrackerTests.cs b/tests/PostHog.Unity.Tests/FlagCalledTrackerTests.cs index f89399b..f782dbd 100644 --- a/tests/PostHog.Unity.Tests/FlagCalledTrackerTests.cs +++ b/tests/PostHog.Unity.Tests/FlagCalledTrackerTests.cs @@ -180,7 +180,7 @@ public void AllowsRetrackingAfterReset() public class ThreadSafety { [Fact] - public async Task ConcurrentAccess_DoesNotCorruptState() + public async Task ConcurrentUniqueKeys_AreAllTrackedAndRemembered() { var tracker = new FlagCalledTracker(1000); var tasks = new List(); @@ -207,6 +207,13 @@ public async Task ConcurrentAccess_DoesNotCorruptState() // Each unique combination should be tracked exactly once Assert.Equal(1000, trackedCount); + for (var threadId = 0; threadId < 10; threadId++) + { + for (var j = 0; j < 100; j++) + { + Assert.False(tracker.ShouldTrack($"user-{threadId}-{j}", "flag", true)); + } + } } } } diff --git a/tests/PostHog.Unity.Tests/JsonSerializerTests.cs b/tests/PostHog.Unity.Tests/JsonSerializerTests.cs index 29be798..ce389cd 100644 --- a/tests/PostHog.Unity.Tests/JsonSerializerTests.cs +++ b/tests/PostHog.Unity.Tests/JsonSerializerTests.cs @@ -103,11 +103,11 @@ public void WithDictionaryContainingMultipleValues_ReturnsObject() }; var result = JsonSerializer.Serialize(dict); - Assert.StartsWith("{", result); - Assert.EndsWith("}", result); - Assert.Contains("\"name\":\"test\"", result); - Assert.Contains("\"count\":42", result); - Assert.Contains("\"active\":true", result); + using var document = System.Text.Json.JsonDocument.Parse(result); + var root = document.RootElement; + Assert.Equal("test", root.GetProperty("name").GetString()); + Assert.Equal(42, root.GetProperty("count").GetInt32()); + Assert.True(root.GetProperty("active").GetBoolean()); } [Fact] @@ -180,12 +180,14 @@ public void WithBasicEvent_ReturnsValidJson() var result = JsonSerializer.SerializeEvent(evt); - Assert.Contains("\"event\":\"test_event\"", result); - Assert.Contains("\"distinct_id\":\"user123\"", result); - Assert.Contains("\"uuid\":", result); - Assert.Contains($"\"timestamp\":\"{evt.Timestamp}\"", result); + using var document = System.Text.Json.JsonDocument.Parse(result); + var root = document.RootElement; + Assert.Equal("test_event", root.GetProperty("event").GetString()); + Assert.Equal("user123", root.GetProperty("distinct_id").GetString()); + Assert.Equal(evt.Uuid, root.GetProperty("uuid").GetString()); + Assert.Equal(evt.Timestamp, root.GetProperty("timestamp").GetString()); Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$", evt.Timestamp); - Assert.Contains("\"properties\":{}", result); + Assert.Empty(root.GetProperty("properties").EnumerateObject()); } [Fact] @@ -200,8 +202,10 @@ public void WithProperties_IncludesProperties() var result = JsonSerializer.SerializeEvent(evt); - Assert.Contains("\"$lib\":\"posthog-unity\"", result); - Assert.Contains("\"custom\":\"value\"", result); + using var document = System.Text.Json.JsonDocument.Parse(result); + var properties = document.RootElement.GetProperty("properties"); + Assert.Equal("posthog-unity", properties.GetProperty("$lib").GetString()); + Assert.Equal("value", properties.GetProperty("custom").GetString()); } } @@ -214,10 +218,12 @@ public void WithEmptyBatch_ReturnsValidJson() var result = JsonSerializer.SerializeBatch(payload); - Assert.Contains("\"api_key\":\"test_api_key\"", result); - Assert.Contains($"\"sent_at\":\"{payload.SentAt}\"", result); + using var document = System.Text.Json.JsonDocument.Parse(result); + var root = document.RootElement; + Assert.Equal("test_api_key", root.GetProperty("api_key").GetString()); + Assert.Equal(payload.SentAt, root.GetProperty("sent_at").GetString()); Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$", payload.SentAt); - Assert.Contains("\"batch\":[]", result); + Assert.Empty(root.GetProperty("batch").EnumerateArray()); } [Fact] @@ -232,10 +238,13 @@ public void WithEvents_IncludesEvents() var result = JsonSerializer.SerializeBatch(payload); - Assert.Contains("\"event\":\"event1\"", result); - Assert.Contains("\"event\":\"event2\"", result); - Assert.Contains("\"distinct_id\":\"user1\"", result); - Assert.Contains("\"distinct_id\":\"user2\"", result); + using var document = System.Text.Json.JsonDocument.Parse(result); + var batch = document.RootElement.GetProperty("batch"); + Assert.Equal(2, batch.GetArrayLength()); + Assert.Equal("event1", batch[0].GetProperty("event").GetString()); + Assert.Equal("user1", batch[0].GetProperty("distinct_id").GetString()); + Assert.Equal("event2", batch[1].GetProperty("event").GetString()); + Assert.Equal("user2", batch[1].GetProperty("distinct_id").GetString()); } } diff --git a/tests/PostHog.Unity.Tests/LruCacheTests.cs b/tests/PostHog.Unity.Tests/LruCacheTests.cs index 15bf48a..c4c1275 100644 --- a/tests/PostHog.Unity.Tests/LruCacheTests.cs +++ b/tests/PostHog.Unity.Tests/LruCacheTests.cs @@ -10,8 +10,15 @@ public class TheConstructor public void WithPositiveCapacity_SetsCapacity() { var cache = new LruCache(10); + for (var i = 0; i < 11; i++) + { + cache.Set($"key{i}", i); + } - Assert.Equal(0, cache.Count); + Assert.Equal(10, cache.Count); + Assert.False(cache.ContainsKey("key0")); + Assert.True(cache.TryGet("key10", out var value)); + Assert.Equal(10, value); } [Fact] @@ -280,8 +287,17 @@ public async Task ConcurrentAccess_DoesNotCorruptCache() await Task.WhenAll(tasks); - // Cache should be in valid state with at most capacity items - Assert.True(cache.Count <= 100); + Assert.Equal(100, cache.Count); + var retained = 0; + for (var key = 0; key < 1000; key++) + { + if (cache.TryGet(key, out var value)) + { + retained++; + Assert.Equal(key % 100, value); + } + } + Assert.Equal(100, retained); } } } diff --git a/tests/PostHog.Unity.Tests/NetworkClientTests.cs b/tests/PostHog.Unity.Tests/NetworkClientTests.cs index 1494cba..6979246 100644 --- a/tests/PostHog.Unity.Tests/NetworkClientTests.cs +++ b/tests/PostHog.Unity.Tests/NetworkClientTests.cs @@ -8,13 +8,20 @@ public class NetworkClientTests { public class TheFeatureFlagsRetryPolicy { - [Fact] - public void RetriesTransientConnectionErrorsWithoutHttpStatus() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("TIMEOUT")] + [InlineData("Connection reset by peer")] + [InlineData("request timed out")] + [InlineData("EOF")] + [InlineData("connection lost")] + public void RetriesTransientConnectionErrorsWithoutHttpStatus(string error) { var shouldRetry = NetworkClient.ShouldRetryFeatureFlagsRequest( UnityWebRequest.Result.ConnectionError, 0, - "Connection reset by peer" + error ); Assert.True(shouldRetry); @@ -280,10 +287,76 @@ int retryableStatusCode Assert.Equal(retryableStatusCode, statusCode); } + [Theory] + [InlineData(0, 502)] + [InlineData(0, 0)] + [InlineData(3, 400)] + [InlineData(3, 401)] + [InlineData(3, 429)] + [InlineData(3, 500)] + [InlineData(3, 503)] + public void TerminalFailure_DoesNotRetryOrDelay(int maxRetries, int responseCode) + { + var request = + responseCode == 0 + ? FakeFeatureFlagsRequest.ConnectionError("EOF") + : FakeFeatureFlagsRequest.ProtocolError("HTTP error", responseCode); + var requests = new Queue(new[] { request }); + var sentRequests = new List(); + var delays = new List(); + var client = CreateRetryClient(maxRetries, requests, sentRequests, delays); + var completions = 0; + + RunCoroutine( + client.FetchFeatureFlags( + "user", + null, + null, + null, + null, + (json, status) => + { + completions++; + Assert.Null(json); + Assert.Equal(responseCode, status); + } + ) + ); + + Assert.Equal(1, completions); + Assert.Single(sentRequests); + Assert.True(request.WasSent); + Assert.Equal(1, request.DisposeCount); + Assert.Empty(delays); + } + + [Fact] + public void RetryLoop_DisposesEveryRequestAndDelaysOnlyBetweenAttempts() + { + var requests = new Queue( + new[] + { + FakeFeatureFlagsRequest.ConnectionError("EOF"), + FakeFeatureFlagsRequest.ProtocolError("Bad Gateway", 502), + FakeFeatureFlagsRequest.Success("{}", 200), + } + ); + var sentRequests = new List(); + var delays = new List(); + var client = CreateRetryClient(3, requests, sentRequests, delays); + + RunCoroutine(client.FetchFeatureFlags("user", null, null, null, null, null)); + + Assert.Equal(3, sentRequests.Count); + Assert.All(sentRequests, request => Assert.Equal(1, request.DisposeCount)); + Assert.Equal(new[] { 1, 2 }, delays); + } + static NetworkClient CreateRetryClient( int maxRetries, Queue requests, - List sentRequests + List sentRequests, + List delays = null ) { return new NetworkClient( @@ -299,7 +372,12 @@ List sentRequests sentRequests.Add(request); return request; }, - _ => EmptyCoroutine() + attempt => + { + Assert.Equal(1, sentRequests.Last().DisposeCount); + delays?.Add(attempt); + return EmptyCoroutine(); + } ); } @@ -342,6 +420,7 @@ string text public string Error { get; } public string Text => _text; public bool WasSent { get; private set; } + public int DisposeCount { get; private set; } public static FakeFeatureFlagsRequest ConnectionError(string error) { @@ -379,7 +458,7 @@ public object Send() return EmptyCoroutine(); } - public void Dispose() { } + public void Dispose() => DisposeCount++; } } } diff --git a/tests/PostHog.Unity.Tests/PostHogFeatureFlagTests.cs b/tests/PostHog.Unity.Tests/PostHogFeatureFlagTests.cs index 077de4e..4ddd071 100644 --- a/tests/PostHog.Unity.Tests/PostHogFeatureFlagTests.cs +++ b/tests/PostHog.Unity.Tests/PostHogFeatureFlagTests.cs @@ -63,6 +63,7 @@ public void WithPayload_StoresPayload() var flag = new PostHogFeatureFlag("test-flag", true, payload); Assert.True(flag.HasPayload); + Assert.Equal("value", flag.GetPayload>()["key"]); } [Fact] @@ -253,7 +254,8 @@ public void WithStringJsonPayload_ParsesJson() var json = flag.GetPayloadJson(); - Assert.False(json.IsNull); + Assert.True(json.IsObject); + Assert.True(json["enabled"].GetBool()); } } diff --git a/tests/PostHog.Unity.Tests/PostHogJsonTests.cs b/tests/PostHog.Unity.Tests/PostHogJsonTests.cs index 3a31abe..15a2f20 100644 --- a/tests/PostHog.Unity.Tests/PostHogJsonTests.cs +++ b/tests/PostHog.Unity.Tests/PostHogJsonTests.cs @@ -487,8 +487,7 @@ public void OnObject_ReturnsKeys() var keys = json.Keys.ToList(); - Assert.Contains("a", keys); - Assert.Contains("b", keys); + Assert.Equal(new[] { "a", "b" }, keys.OrderBy(key => key)); } [Fact] @@ -534,8 +533,7 @@ public void OnList_ReturnsList() var result = json.AsList(); Assert.NotNull(result); - Assert.Equal(3, result.Count); - Assert.Equal(1, result[0].GetInt()); + Assert.Equal(new[] { 1, 2, 3 }, result.Select(item => item.GetInt())); } [Fact] @@ -547,7 +545,7 @@ public void OnArray_ReturnsList() var result = json.AsList(); Assert.NotNull(result); - Assert.Equal(2, result.Count); + Assert.Equal(new[] { "a", "b" }, result.Select(item => item.GetString())); } [Fact] diff --git a/tests/PostHog.Unity.Tests/PostHogSettingsTests.cs b/tests/PostHog.Unity.Tests/PostHogSettingsTests.cs index de6b9b4..af04cea 100644 --- a/tests/PostHog.Unity.Tests/PostHogSettingsTests.cs +++ b/tests/PostHog.Unity.Tests/PostHogSettingsTests.cs @@ -12,7 +12,7 @@ internal static class PostHogSettingsTestHelper /// /// Creates an uninitialized PostHogSettings instance for testing. /// We use RuntimeHelpers.GetUninitializedObject to bypass ScriptableObject.CreateInstance - /// which requires Unity runtime. Field defaults are applied manually. + /// which requires Unity runtime. These are fixture values, not proof of constructor defaults. /// public static PostHogSettings CreateSettings() { @@ -20,7 +20,7 @@ public static PostHogSettings CreateSettings() var settings = (PostHogSettings) RuntimeHelpers.GetUninitializedObject(typeof(PostHogSettings)); - // Manually set default values that would normally be set by field initializers + // Supply valid serialized values without exercising the native constructor. SetField(settings, "_host", "https://us.i.posthog.com"); SetField(settings, "_autoInitialize", true); SetField(settings, "_flushAt", 20); @@ -32,6 +32,7 @@ public static PostHogSettings CreateSettings() SetField(settings, "_logLevel", PostHogLogLevel.Warning); SetField(settings, "_reuseAnonymousId", false); SetField(settings, "_preloadFeatureFlags", true); + SetField(settings, "_featureFlagRequestMaxRetries", 1); SetField(settings, "_sendFeatureFlagEvent", true); SetField(settings, "_sendDefaultPersonPropertiesForFlags", true); SetField(settings, "_captureExceptions", true); @@ -57,10 +58,12 @@ public class PostHogSettingsTests { public class TheDefaultValues { - [Fact] + [Fact( + Skip = "ScriptableObject.CreateInstance requires the Unity native runtime; constructor defaults cannot be verified with an uninitialized fixture." + )] public void MatchPostHogConfigDefaults() { - var settings = PostHogSettingsTestHelper.CreateSettings(); + var settings = UnityEngine.ScriptableObject.CreateInstance(); // Verify all defaults match PostHogConfig defaults Assert.Null(settings.ApiKey); @@ -75,6 +78,7 @@ public void MatchPostHogConfigDefaults() Assert.Equal(PostHogLogLevel.Warning, settings.LogLevel); Assert.False(settings.ReuseAnonymousId); Assert.True(settings.PreloadFeatureFlags); + Assert.Equal(1, settings.FeatureFlagRequestMaxRetries); Assert.True(settings.SendFeatureFlagEvent); Assert.True(settings.SendDefaultPersonPropertiesForFlags); Assert.True(settings.CaptureExceptions); @@ -119,6 +123,7 @@ public void MapsAllPropertiesToPostHogConfig() ); PostHogSettingsTestHelper.SetField(settings, "_captureExceptions", false); PostHogSettingsTestHelper.SetField(settings, "_exceptionDebounceIntervalMs", 2000); + PostHogSettingsTestHelper.SetField(settings, "_featureFlagRequestMaxRetries", 4); PostHogSettingsTestHelper.SetField(settings, "_captureExceptionsInEditor", false); var config = settings.ToConfig(); @@ -138,18 +143,19 @@ public void MapsAllPropertiesToPostHogConfig() Assert.False(config.SendDefaultPersonPropertiesForFlags); Assert.False(config.CaptureExceptions); Assert.Equal(2000, config.ExceptionDebounceIntervalMs); + Assert.Equal(4, config.FeatureFlagRequestMaxRetries); Assert.False(config.CaptureExceptionsInEditor); } [Fact] - public void WithDefaultValues_CreatesValidConfig() + public void WithValidSerializedValues_CreatesValidConfig() { var settings = PostHogSettingsTestHelper.CreateSettings(); PostHogSettingsTestHelper.SetField(settings, "_apiKey", "phc_test_key"); var config = settings.ToConfig(); - // Should not throw and should match defaults + config.Validate(); Assert.Equal("phc_test_key", config.ApiKey); Assert.Equal("https://us.i.posthog.com", config.Host); Assert.Equal(20, config.FlushAt); @@ -204,6 +210,7 @@ public void ExposeAllSerializedFields() ); PostHogSettingsTestHelper.SetField(settings, "_captureExceptions", false); PostHogSettingsTestHelper.SetField(settings, "_exceptionDebounceIntervalMs", 5000); + PostHogSettingsTestHelper.SetField(settings, "_featureFlagRequestMaxRetries", 3); PostHogSettingsTestHelper.SetField(settings, "_captureExceptionsInEditor", false); Assert.Equal("test_key", settings.ApiKey); @@ -222,6 +229,7 @@ public void ExposeAllSerializedFields() Assert.False(settings.SendDefaultPersonPropertiesForFlags); Assert.False(settings.CaptureExceptions); Assert.Equal(5000, settings.ExceptionDebounceIntervalMs); + Assert.Equal(3, settings.FeatureFlagRequestMaxRetries); Assert.False(settings.CaptureExceptionsInEditor); } } @@ -260,9 +268,6 @@ public void WithNullApiKey_ReturnsApiKeyMissing() var settings = PostHogSettingsTestHelper.CreateSettings(); PostHogSettingsTestHelper.SetField(settings, "_autoInitialize", true); PostHogSettingsTestHelper.SetField(settings, "_apiKey", null); - - // Disable logging to avoid Unity runtime dependencies - PostHogLogger.SetLogLevel(PostHogLogLevel.None); var result = PostHogAutoInitializer.TryInitialize(settings); Assert.Equal(PostHogAutoInitializer.InitializationResult.ApiKeyMissing, result); @@ -274,9 +279,6 @@ public void WithEmptyApiKey_ReturnsApiKeyMissing() var settings = PostHogSettingsTestHelper.CreateSettings(); PostHogSettingsTestHelper.SetField(settings, "_autoInitialize", true); PostHogSettingsTestHelper.SetField(settings, "_apiKey", ""); - - // Disable logging to avoid Unity runtime dependencies - PostHogLogger.SetLogLevel(PostHogLogLevel.None); var result = PostHogAutoInitializer.TryInitialize(settings); Assert.Equal(PostHogAutoInitializer.InitializationResult.ApiKeyMissing, result); @@ -288,9 +290,6 @@ public void WithWhitespaceApiKey_ReturnsApiKeyMissing() var settings = PostHogSettingsTestHelper.CreateSettings(); PostHogSettingsTestHelper.SetField(settings, "_autoInitialize", true); PostHogSettingsTestHelper.SetField(settings, "_apiKey", " "); - - // Disable logging to avoid Unity runtime dependencies - PostHogLogger.SetLogLevel(PostHogLogLevel.None); var result = PostHogAutoInitializer.TryInitialize(settings); Assert.Equal(PostHogAutoInitializer.InitializationResult.ApiKeyMissing, result); diff --git a/tests/PostHog.Unity.Tests/ReplayQueueTests.cs b/tests/PostHog.Unity.Tests/ReplayQueueTests.cs index c8f34ce..31fe81e 100644 --- a/tests/PostHog.Unity.Tests/ReplayQueueTests.cs +++ b/tests/PostHog.Unity.Tests/ReplayQueueTests.cs @@ -23,6 +23,69 @@ public void EnqueueCreatesReplayEnvelopeWithUtcTimestamp() var envelope = Assert.Single(GetQueuedEvents(queue)); Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$", envelope.Timestamp); Assert.Equal(123L, Assert.Single(envelope.SnapshotData).Timestamp); + Assert.True(Guid.TryParse(envelope.Uuid, out _)); + var serialized = envelope.ToDictionary("test-api-key"); + Assert.Equal("$snapshot", serialized["event"]); + Assert.Equal("test-api-key", serialized["api_key"]); + Assert.Equal("distinct-id", serialized["distinct_id"]); + Assert.Equal(envelope.Timestamp, serialized["timestamp"]); + Assert.Equal(envelope.Uuid, serialized["uuid"]); + var properties = Assert.IsType>(serialized["properties"]); + Assert.Equal("session-id", properties["$session_id"]); + Assert.Equal("session-id", properties["$window_id"]); + Assert.Equal("mobile", properties["$snapshot_source"]); + var snapshots = Assert.IsType>>( + properties["$snapshot_data"] + ); + var snapshot = Assert.Single(snapshots); + Assert.Equal(123L, snapshot["timestamp"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Enqueue_WithoutSession_DropsSnapshot(string sessionId) + { + var queue = new ReplayQueue( + new PostHogSessionReplayConfig(), + "key", + "https://example.com", + () => "user", + () => sessionId + ); + queue.Enqueue(new List { RREvent.CreateMeta(100, 200, "Home", 123L) }); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Enqueue_EmptySnapshots_DoesNotCreateEnvelope() + { + var queue = new ReplayQueue( + new PostHogSessionReplayConfig(), + "key", + "https://example.com", + () => "user", + () => "session" + ); + queue.Enqueue(null); + queue.Enqueue(new List()); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Clear_RemovesQueuedSnapshots() + { + var queue = new ReplayQueue( + new PostHogSessionReplayConfig(), + "key", + "https://example.com", + () => "user", + () => "session" + ); + queue.Enqueue(new List { RREvent.CreateMeta(100, 200, "Home", 123L) }); + Assert.Equal(1, queue.Count); + queue.Clear(); + Assert.Equal(0, queue.Count); } [Fact] diff --git a/tests/PostHog.Unity.Tests/SessionManagerTests.cs b/tests/PostHog.Unity.Tests/SessionManagerTests.cs index f5de434..61d510c 100644 --- a/tests/PostHog.Unity.Tests/SessionManagerTests.cs +++ b/tests/PostHog.Unity.Tests/SessionManagerTests.cs @@ -77,6 +77,59 @@ public void OnForeground_StartsNewSessionAfterBackgroundTimeout() Assert.NotEqual(originalSessionId, newSessionId); } + [Fact] + public void Touch_ExtendsInactivityDeadline() + { + var now = new DateTime(2026, 4, 2, 0, 0, 0, DateTimeKind.Utc); + var manager = new SessionManager(() => now); + var original = manager.SessionId; + now = now.AddMinutes(29); + manager.Touch(); + now = now.AddMinutes(30); + Assert.Equal(original, manager.SessionId); + now = now.AddTicks(1); + Assert.NotEqual(original, manager.SessionId); + } + + [Fact] + public void EndSession_DoesNotRestartInBackgroundUntilForegrounded() + { + var now = new DateTime(2026, 4, 2, 0, 0, 0, DateTimeKind.Utc); + var manager = new SessionManager(() => now); + var original = manager.SessionId; + manager.OnBackground(); + manager.EndSession(); + manager.EndSession(); + manager.Touch(); + Assert.Null(manager.SessionId); + manager.OnForeground(); + Assert.NotNull(manager.SessionId); + Assert.NotEqual(original, manager.SessionId); + } + + [Fact] + public void OnForeground_BeforeTimeout_PreservesSession() + { + var now = new DateTime(2026, 4, 2, 0, 0, 0, DateTimeKind.Utc); + var manager = new SessionManager(() => now); + var original = manager.SessionId; + manager.OnBackground(); + now = now.AddMinutes(20); + manager.OnForeground(); + now = now.AddMinutes(20); + Assert.Equal(original, manager.SessionId); + } + + [Fact] + public void StartNewSession_RotatesActiveSession() + { + var manager = new SessionManager(); + var original = manager.SessionId; + manager.StartNewSession(); + Assert.NotNull(manager.SessionId); + Assert.NotEqual(original, manager.SessionId); + } + [Fact] public void NewSessionManagerInstanceStartsNewSessionWithinInactivityTimeout() { diff --git a/tests/PostHog.Unity.Tests/UnityExceptionIntegrationTests.cs b/tests/PostHog.Unity.Tests/UnityExceptionIntegrationTests.cs index 70af326..3f6facf 100644 --- a/tests/PostHog.Unity.Tests/UnityExceptionIntegrationTests.cs +++ b/tests/PostHog.Unity.Tests/UnityExceptionIntegrationTests.cs @@ -10,7 +10,7 @@ namespace PostHogUnity.Tests /// must opt in with [Collection("UnityGlobals")] — xUnit does not propagate the /// outer-class attribute to nested test classes. /// - [CollectionDefinition("UnityGlobals")] + [CollectionDefinition("UnityGlobals", DisableParallelization = true)] public class UnityGlobalsCollection { } /// @@ -72,6 +72,7 @@ params object[] args sealed class ThrowingLogFormatHandler : ILogHandler { public int LogExceptionCount; + public int LogFormatCount; public Exception LastException; public void LogException(Exception exception, UnityEngine.Object context) @@ -85,7 +86,11 @@ public void LogFormat( UnityEngine.Object context, string format, params object[] args - ) => throw new InvalidOperationException("log handler broken"); + ) + { + LogFormatCount++; + throw new InvalidOperationException("log handler broken"); + } } [Collection("UnityGlobals")] @@ -104,6 +109,7 @@ public void AfterRegister_LogExceptionInvokesCallbackWithException() var thrown = new InvalidOperationException("boom"); ((ILogHandler)integration).LogException(thrown, null); + Assert.Same(integration, Debug.unityLogger.logHandler); Assert.Same(thrown, captured); } @@ -145,6 +151,7 @@ public void AfterUnregister_LogExceptionDoesNotInvokeCallback() int callbackInvocations = 0; integration.Register(_ => callbackInvocations++); integration.Unregister(); + Assert.Same(handler, Debug.unityLogger.logHandler); ((ILogHandler)integration).LogException(new Exception("after"), null); @@ -248,6 +255,7 @@ public void WhenLoggerAlsoThrows_StillForwardsExceptionToPreviousHandler() // the contract under test; the forwarding guarantee is. } + Assert.Equal(1, handler.LogFormatCount); Assert.Equal(1, handler.LogExceptionCount); Assert.Same(raised, handler.LastException); } diff --git a/tests/coverage.runsettings b/tests/coverage.runsettings new file mode 100644 index 0000000..22f5f4f --- /dev/null +++ b/tests/coverage.runsettings @@ -0,0 +1,16 @@ + + + + + + + + true + [*]AutoGeneratedProgram + **/tests/** + cobertura + + + + +