From 99341836f30d17cfa379123dde8265ec555ee60b Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 19:49:30 -0500 Subject: [PATCH 1/8] Migrate SQLite-dependent Eventing tests to Integration project and extract shared TestUtils library --- EventLogExpert.slnx | 3 + ...LogExpert.Eventing.IntegrationTests.csproj | 10 +++ .../ProviderDbContextTests.cs | 34 +--------- .../EventMessageProviderIntegrationTests.cs | 2 +- .../Providers/ProviderMetadataTests.cs | 2 +- .../Providers/RegistryProviderTests.cs | 2 +- .../Readers/EventLogInformationTests.cs | 2 +- .../Readers/EventLogReaderTests.cs | 2 +- .../Readers/EventLogSessionTests.cs | 2 +- .../Readers/EventLogWatcherTests.cs | 2 +- ...EventProviderDatabaseEventResolverTests.cs | 37 ++--------- .../Resolvers/VersatileEventResolverTests.cs | 63 +++---------------- .../TestUtils/Constants/Constants.Provider.cs | 18 ------ .../xunit.runner.json | 5 ++ .../CompressionTestUtils.cs | 2 +- .../Constants/Constants.Provider.cs | 2 +- .../Constants/Constants.Resolver.cs | 2 +- .../EventLogExpert.Eventing.TestUtils.csproj | 11 ++++ .../EventUtils.cs | 4 +- .../SqliteTestDb.cs | 63 +++++++++++++++++++ .../EventLogExpert.Eventing.Tests.csproj | 1 + .../CompressedJsonValueConverterTests.cs | 2 +- .../ProviderDetailsMergerTests.cs | 2 +- .../Providers/EventMessageProviderTests.cs | 2 +- .../Providers/ProviderDetailsTests.cs | 4 +- .../Resolvers/EventResolverBaseTests.cs | 4 +- .../LocalProviderEventResolverTests.cs | 4 +- 27 files changed, 131 insertions(+), 156 deletions(-) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/ProviderDatabase/ProviderDbContextTests.cs (98%) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/Resolvers/EventProviderDatabaseEventResolverTests.cs (95%) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/Resolvers/VersatileEventResolverTests.cs (90%) delete mode 100644 tests/Integration/EventLogExpert.Eventing.IntegrationTests/TestUtils/Constants/Constants.Provider.cs create mode 100644 tests/Integration/EventLogExpert.Eventing.IntegrationTests/xunit.runner.json rename tests/{Unit/EventLogExpert.Eventing.Tests/TestUtils => Shared/EventLogExpert.Eventing.TestUtils}/CompressionTestUtils.cs (97%) rename tests/{Unit/EventLogExpert.Eventing.Tests/TestUtils => Shared/EventLogExpert.Eventing.TestUtils}/Constants/Constants.Provider.cs (96%) rename tests/{Unit/EventLogExpert.Eventing.Tests/TestUtils => Shared/EventLogExpert.Eventing.TestUtils}/Constants/Constants.Resolver.cs (95%) create mode 100644 tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj rename tests/{Unit/EventLogExpert.Eventing.Tests/TestUtils => Shared/EventLogExpert.Eventing.TestUtils}/EventUtils.cs (97%) create mode 100644 tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs diff --git a/EventLogExpert.slnx b/EventLogExpert.slnx index 67aabbb4..74719cac 100644 --- a/EventLogExpert.slnx +++ b/EventLogExpert.slnx @@ -25,4 +25,7 @@ + + + diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/EventLogExpert.Eventing.IntegrationTests.csproj b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/EventLogExpert.Eventing.IntegrationTests.csproj index c9c039b8..12ca6fc4 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/EventLogExpert.Eventing.IntegrationTests.csproj +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/EventLogExpert.Eventing.IntegrationTests.csproj @@ -8,6 +8,16 @@ + + + + + + \ No newline at end of file diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDbContextTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs similarity index 98% rename from tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDbContextTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs index 7e98d6db..1f0adb85 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDbContextTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs @@ -4,13 +4,13 @@ using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.ProviderDatabase; using EventLogExpert.Eventing.Providers; -using EventLogExpert.Eventing.Tests.TestUtils; +using EventLogExpert.Eventing.TestUtils; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NSubstitute; using System.Text; -namespace EventLogExpert.Eventing.Tests.ProviderDatabase; +namespace EventLogExpert.Eventing.IntegrationTests.ProviderDatabase; public sealed class ProviderDbContextTests : IDisposable { @@ -1040,35 +1040,7 @@ private static void AssertProviderDetailsRowCount(string dbPath, int expectedRow Assert.Equal(expectedRows, count); } - private static void DeleteDatabaseFile(string path) - { - try - { - if (!File.Exists(path)) { return; } - - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - for (int i = 0; i < 10; i++) - { - try - { - File.Delete(path); - break; - } - catch (IOException) - { - Thread.Sleep(200); - } - } - } - catch - { - // Cleanup is best effort - } - } + private static void DeleteDatabaseFile(string path) => SqliteTestDb.Delete(path); private static void InsertLegacyRow( string dbPath, diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs index 83ca3034..3c5c89ee 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Interop; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs index e2ce5765..c5d17e1d 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; using NSubstitute; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs index 1d799be8..379a7df0 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; using Microsoft.Win32; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs index ac3a777b..aca1d610 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; namespace EventLogExpert.Eventing.IntegrationTests.Readers; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs index 41b35138..c99062db 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; namespace EventLogExpert.Eventing.IntegrationTests.Readers; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs index 6cdd0870..2ad22f4b 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; namespace EventLogExpert.Eventing.IntegrationTests.Readers; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs index 2406e1fe..64bb3503 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; using System.Collections.Concurrent; using System.Diagnostics; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventProviderDatabaseEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs similarity index 95% rename from tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventProviderDatabaseEventResolverTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs index 445f233c..b165a1c6 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventProviderDatabaseEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs @@ -6,14 +6,13 @@ using EventLogExpert.Eventing.ProviderDatabase; using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; -using EventLogExpert.Eventing.Tests.TestUtils; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; -using Microsoft.Data.Sqlite; +using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Collections.Concurrent; using System.Collections.Immutable; -namespace EventLogExpert.Eventing.Tests.Resolvers; +namespace EventLogExpert.Eventing.IntegrationTests.Resolvers; public sealed class EventResolverDatabaseTests { @@ -724,33 +723,5 @@ public void LoadProviderDetails_WithUnknownProvider_ShouldAddEmptyDetails() Assert.Null(exception); } - private static void DeleteDatabaseFile(string path, int maxRetries = 10) - { - if (!File.Exists(path)) { return; } - - for (int i = 0; i < maxRetries; i++) - { - try - { - // Clear SQLite connection pool - SqliteConnection.ClearAllPools(); - - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - File.Delete(path); - return; - } - catch (IOException) when (i < maxRetries - 1) - { - Thread.Sleep(200); - } - catch (IOException) - { - // If we still can't delete after retries, just ignore - OS will clean up temp files - return; - } - } - } + private static void DeleteDatabaseFile(string path) => SqliteTestDb.Delete(path); } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/VersatileEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs similarity index 90% rename from tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/VersatileEventResolverTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs index 80919016..1ad4f6fb 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/VersatileEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs @@ -8,13 +8,12 @@ using EventLogExpert.Eventing.Providers; using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; -using EventLogExpert.Eventing.Tests.TestUtils; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; -using Microsoft.Data.Sqlite; +using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Collections.Immutable; -namespace EventLogExpert.Eventing.Tests.Resolvers; +namespace EventLogExpert.Eventing.IntegrationTests.Resolvers; public sealed class EventResolverTests { @@ -56,13 +55,7 @@ public void Constructor_WithDatabaseCollection_ShouldUseDatabaseResolver() } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -146,13 +139,7 @@ public void Dispose_ThenLoadProviderDetails_WithDatabaseResolver_ShouldThrowObje } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -201,13 +188,7 @@ public void Dispose_ThenResolveEvent_WithDatabaseResolver_ShouldThrowObjectDispo } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -309,13 +290,7 @@ public void LoadProviderDetails_WithDatabaseResolver_ShouldResolve() } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -433,13 +408,7 @@ public void ResolveEvent_WithDatabaseResolver_ShouldResolveEvent() } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -532,13 +501,7 @@ public void ResolveEvent_WithMixedDbAndUnknownProviders_ShouldOnlyApplyDatabaseT } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } @@ -631,13 +594,7 @@ public void SwitchBetweenResolvers_WithDifferentInstances_ShouldWorkIndependentl } finally { - if (File.Exists(dbPath)) - { - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - File.Delete(dbPath); - } + SqliteTestDb.Delete(dbPath); } } } diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/TestUtils/Constants/Constants.Provider.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/TestUtils/Constants/Constants.Provider.cs deleted file mode 100644 index 91b986c1..00000000 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/TestUtils/Constants/Constants.Provider.cs +++ /dev/null @@ -1,18 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Eventing.IntegrationTests.TestUtils.Constants; - -public sealed partial class Constants -{ - public const string ApplicationLogName = "Application"; - - public const string KernelGeneralLogName = "Microsoft-Windows-Kernel-General"; - public const string PowerShellLogName = "Microsoft-Windows-PowerShell"; - public const string SecurityAuditingLogName = "Microsoft-Windows-Security-Auditing"; - public const string SecurityLogName = "Security"; - public const string ServiceControlManagerLogName = "Service Control Manager"; - public const string SystemLogName = "System"; - - public const string TestProviderName = "TestProvider"; -} diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..77778efd --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/CompressionTestUtils.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/CompressionTestUtils.cs similarity index 97% rename from tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/CompressionTestUtils.cs rename to tests/Shared/EventLogExpert.Eventing.TestUtils/CompressionTestUtils.cs index 61280e1e..ff5bca71 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/CompressionTestUtils.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/CompressionTestUtils.cs @@ -4,7 +4,7 @@ using System.IO.Compression; using System.Text; -namespace EventLogExpert.Eventing.Tests.TestUtils; +namespace EventLogExpert.Eventing.TestUtils; public static class CompressionTestUtils { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Provider.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs similarity index 96% rename from tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Provider.cs rename to tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs index 08819b05..1cd11ec3 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Provider.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -namespace EventLogExpert.Eventing.Tests.TestUtils.Constants; +namespace EventLogExpert.Eventing.TestUtils.Constants; public sealed partial class Constants { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Resolver.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Resolver.cs similarity index 95% rename from tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Resolver.cs rename to tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Resolver.cs index 7840004b..ca9ce627 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/Constants/Constants.Resolver.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Resolver.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -namespace EventLogExpert.Eventing.Tests.TestUtils.Constants; +namespace EventLogExpert.Eventing.TestUtils.Constants; public sealed partial class Constants { diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj new file mode 100644 index 00000000..eacb8d28 --- /dev/null +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventLogExpert.Eventing.TestUtils.csproj @@ -0,0 +1,11 @@ + + + + false + + + + + + + diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/EventUtils.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs similarity index 97% rename from tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/EventUtils.cs rename to tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs index 88cfd6b2..a28a7ee7 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/TestUtils/EventUtils.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/EventUtils.cs @@ -3,9 +3,9 @@ using EventLogExpert.Eventing.Providers; using EventLogExpert.Eventing.Readers; -using static EventLogExpert.Eventing.Tests.TestUtils.Constants.Constants; +using static EventLogExpert.Eventing.TestUtils.Constants.Constants; -namespace EventLogExpert.Eventing.Tests.TestUtils; +namespace EventLogExpert.Eventing.TestUtils; public static class EventUtils { diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs new file mode 100644 index 00000000..cd810326 --- /dev/null +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs @@ -0,0 +1,63 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Data.Sqlite; + +namespace EventLogExpert.Eventing.TestUtils; + +/// +/// Helpers for managing SQLite test database files. +/// +public static class SqliteTestDb +{ + /// + /// Deletes a SQLite test database file with retries, after first releasing pooled + /// SqliteConnection handles. + /// + /// + /// + /// uses + /// EF Core's default pooled SqliteConnection. Without + /// the pooled handle still owns the file + /// after the context is disposed and hits a sharing violation. + /// + /// + /// mutates process-wide state, so it must + /// only be called from a test assembly configured for serial execution + /// (xunit.runner.json with parallelizeTestCollections: false) — otherwise it can + /// race with concurrent SQLite work on another thread. + /// + /// + public static void Delete(string? path, int maxAttempts = 10, int delayMs = 200) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) { return; } + + SqliteConnection.ClearAllPools(); + + for (int i = 0; i < maxAttempts; i++) + { + try + { + File.Delete(path); + return; + } + catch (IOException) when (i < maxAttempts - 1) + { + Thread.Sleep(delayMs); + } + catch (UnauthorizedAccessException) when (i < maxAttempts - 1) + { + Thread.Sleep(delayMs); + } + catch (IOException) + { + // Best-effort cleanup; swallow last IOException — OS will reclaim temp files. + return; + } + catch (UnauthorizedAccessException) + { + return; + } + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/EventLogExpert.Eventing.Tests.csproj b/tests/Unit/EventLogExpert.Eventing.Tests/EventLogExpert.Eventing.Tests.csproj index e717f1db..385033d9 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/EventLogExpert.Eventing.Tests.csproj +++ b/tests/Unit/EventLogExpert.Eventing.Tests/EventLogExpert.Eventing.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs index 808e0479..b7e08408 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs @@ -2,7 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.ProviderDatabase; -using EventLogExpert.Eventing.Tests.TestUtils; +using EventLogExpert.Eventing.TestUtils; using System.Text; using System.Text.Json; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDetailsMergerTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDetailsMergerTests.cs index dd0b8645..78027e3b 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDetailsMergerTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderDetailsMergerTests.cs @@ -3,7 +3,7 @@ using EventLogExpert.Eventing.ProviderDatabase; using EventLogExpert.Eventing.Providers; -using EventLogExpert.Eventing.Tests.TestUtils; +using EventLogExpert.Eventing.TestUtils; namespace EventLogExpert.Eventing.Tests.ProviderDatabase; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs index 1637b07e..2ef659fe 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs @@ -3,7 +3,7 @@ using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; namespace EventLogExpert.Eventing.Tests.Providers; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs index cb5b1279..4d2afd7e 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Providers; -using EventLogExpert.Eventing.Tests.TestUtils; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Eventing.TestUtils.Constants; namespace EventLogExpert.Eventing.Tests.Providers; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs index 5e593671..64936184 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs @@ -6,8 +6,8 @@ using EventLogExpert.Eventing.Providers; using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; -using EventLogExpert.Eventing.Tests.TestUtils; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Collections.Concurrent; diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs index 47c09cc1..977ecf6b 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs @@ -4,8 +4,8 @@ using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; -using EventLogExpert.Eventing.Tests.TestUtils; -using EventLogExpert.Eventing.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Collections.Concurrent; From 818e3ebf10f3250d35645cc6d5642277a21951db Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 20:29:35 -0500 Subject: [PATCH 2/8] Properly split integration tests out of the unit tests where appropriate --- EventLogExpert.slnx | 3 +- .../Properties/AssemblyInfo.cs | 2 +- .../DiffDatabaseCommandTests.cs | 6 +- ...Expert.EventDbTool.IntegrationTests.csproj | 23 +++++++ .../GlobalUsings.cs | 0 .../MergeDatabaseCommandTests.cs | 6 +- .../ProviderSourceTests.cs | 6 +- .../TestUtils/Constants/Constants.Database.cs | 2 +- .../TestUtils/DatabaseTestUtils.cs | 63 ++----------------- .../UpgradeDatabaseCommandTests.cs | 4 +- .../xunit.runner.json | 5 ++ .../Database/DatabaseServiceTests.cs | 6 +- .../DebugLog/DebugLogServiceTests.cs | 4 +- .../EventLogExpert.UI.IntegrationTests.csproj | 23 +++++++ .../GlobalUsings.cs | 4 ++ .../TestUtils/Constants/Constants.Database.cs | 27 ++++++++ .../TestUtils/Constants/Constants.DebugLog.cs | 19 ++++++ .../TestUtils/DatabaseSeedUtils.cs | 2 +- .../xunit.runner.json | 5 ++ .../SqliteTestDb.cs | 41 ++++++++++++ .../EventLogExpert.EventDbTool.Tests.csproj | 13 ---- .../TestUtils/Constants/Constants.Database.cs | 44 ------------- .../TestUtils/Constants/Constants.DebugLog.cs | 6 -- 23 files changed, 172 insertions(+), 142 deletions(-) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/DiffDatabaseCommandTests.cs (95%) create mode 100644 tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/EventLogExpert.EventDbTool.IntegrationTests.csproj rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/GlobalUsings.cs (100%) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/MergeDatabaseCommandTests.cs (93%) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/ProviderSourceTests.cs (96%) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/TestUtils/Constants/Constants.Database.cs (83%) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/TestUtils/DatabaseTestUtils.cs (66%) rename tests/{Unit/EventLogExpert.EventDbTool.Tests => Integration/EventLogExpert.EventDbTool.IntegrationTests}/UpgradeDatabaseCommandTests.cs (97%) create mode 100644 tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json rename tests/{Unit/EventLogExpert.UI.Tests => Integration/EventLogExpert.UI.IntegrationTests}/Database/DatabaseServiceTests.cs (99%) rename tests/{Unit/EventLogExpert.UI.Tests => Integration/EventLogExpert.UI.IntegrationTests}/DebugLog/DebugLogServiceTests.cs (99%) create mode 100644 tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj create mode 100644 tests/Integration/EventLogExpert.UI.IntegrationTests/GlobalUsings.cs create mode 100644 tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.Database.cs create mode 100644 tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.DebugLog.cs rename tests/{Unit/EventLogExpert.UI.Tests => Integration/EventLogExpert.UI.IntegrationTests}/TestUtils/DatabaseSeedUtils.cs (98%) create mode 100644 tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json delete mode 100644 tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj delete mode 100644 tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.Database.cs diff --git a/EventLogExpert.slnx b/EventLogExpert.slnx index 74719cac..8fc04935 100644 --- a/EventLogExpert.slnx +++ b/EventLogExpert.slnx @@ -18,12 +18,13 @@ - + + diff --git a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs index 6e18265f..9b47f49a 100644 --- a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs +++ b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs @@ -3,4 +3,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.Tests")] +[assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.IntegrationTests")] diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/DiffDatabaseCommandTests.cs similarity index 95% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/DiffDatabaseCommandTests.cs index 05c8efab..b1e1a2dd 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/DiffDatabaseCommandTests.cs @@ -1,13 +1,13 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.EventDbTool.Tests.TestUtils; -using EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.ProviderDatabase; using NSubstitute; -namespace EventLogExpert.EventDbTool.Tests; +namespace EventLogExpert.EventDbTool.IntegrationTests; public sealed class DiffDatabaseCommandTests : IDisposable { diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/EventLogExpert.EventDbTool.IntegrationTests.csproj b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/EventLogExpert.EventDbTool.IntegrationTests.csproj new file mode 100644 index 00000000..607e3eda --- /dev/null +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/EventLogExpert.EventDbTool.IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + false + true + + + + + + + + + + + + + + diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/GlobalUsings.cs similarity index 100% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/GlobalUsings.cs diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/MergeDatabaseCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MergeDatabaseCommandTests.cs similarity index 93% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/MergeDatabaseCommandTests.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MergeDatabaseCommandTests.cs index afafa34a..83aa76f6 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/MergeDatabaseCommandTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MergeDatabaseCommandTests.cs @@ -1,12 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.EventDbTool.Tests.TestUtils; -using EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using NSubstitute; -namespace EventLogExpert.EventDbTool.Tests; +namespace EventLogExpert.EventDbTool.IntegrationTests; public sealed class MergeDatabaseCommandTests : IDisposable { diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ProviderSourceTests.cs similarity index 96% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ProviderSourceTests.cs index 2176830d..8a8d42c0 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ProviderSourceTests.cs @@ -1,12 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.EventDbTool.Tests.TestUtils; -using EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using NSubstitute; -namespace EventLogExpert.EventDbTool.Tests; +namespace EventLogExpert.EventDbTool.IntegrationTests; public sealed class ProviderSourceTests : IDisposable { diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/Constants/Constants.Database.cs similarity index 83% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/Constants/Constants.Database.cs index cdec3c49..50d18e07 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/Constants/Constants.Database.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -namespace EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +namespace EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; public sealed partial class Constants { diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/DatabaseTestUtils.cs similarity index 66% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/DatabaseTestUtils.cs index fa8dbed6..d9949221 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/TestUtils/DatabaseTestUtils.cs @@ -3,9 +3,10 @@ using EventLogExpert.Eventing.ProviderDatabase; using EventLogExpert.Eventing.Providers; +using EventLogExpert.Eventing.TestUtils; using Microsoft.Data.Sqlite; -namespace EventLogExpert.EventDbTool.Tests.TestUtils; +namespace EventLogExpert.EventDbTool.IntegrationTests.TestUtils; internal static class DatabaseTestUtils { @@ -77,63 +78,7 @@ public static void CreateV4Database(string dbPath, params ProviderDetails[] prov context.SaveChanges(); } - public static void DeleteDatabaseFile(string path) - { - try - { - if (!File.Exists(path)) { return; } - - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - for (var i = 0; i < 10; i++) - { - try - { - File.Delete(path); - break; - } - catch (IOException) - { - Thread.Sleep(200); - } - } - } - catch - { - // Best effort cleanup. - } - } + public static void DeleteDatabaseFile(string path) => SqliteTestDb.Delete(path); - public static void DeleteDirectoryRecursive(string path) - { - try - { - if (!Directory.Exists(path)) { return; } - - SqliteConnection.ClearAllPools(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - for (var i = 0; i < 10; i++) - { - try - { - Directory.Delete(path, recursive: true); - break; - } - catch (IOException) - { - Thread.Sleep(200); - } - } - } - catch - { - // Best effort cleanup. - } - } + public static void DeleteDirectoryRecursive(string path) => SqliteTestDb.DeleteDirectory(path); } diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/UpgradeDatabaseCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/UpgradeDatabaseCommandTests.cs similarity index 97% rename from tests/Unit/EventLogExpert.EventDbTool.Tests/UpgradeDatabaseCommandTests.cs rename to tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/UpgradeDatabaseCommandTests.cs index ee375cf9..c738ddbc 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/UpgradeDatabaseCommandTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/UpgradeDatabaseCommandTests.cs @@ -1,12 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.EventDbTool.Tests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.ProviderDatabase; using NSubstitute; -namespace EventLogExpert.EventDbTool.Tests; +namespace EventLogExpert.EventDbTool.IntegrationTests; public sealed class UpgradeDatabaseCommandTests : IDisposable { diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..77778efd --- /dev/null +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseServiceTests.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs similarity index 99% rename from tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseServiceTests.cs rename to tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs index 5fcf62f8..f29b0844 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseServiceTests.cs +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs @@ -6,13 +6,13 @@ using EventLogExpert.UI.Common.Preferences; using EventLogExpert.UI.Database; using EventLogExpert.UI.Database.Upgrade; -using EventLogExpert.UI.Tests.TestUtils; -using EventLogExpert.UI.Tests.TestUtils.Constants; +using EventLogExpert.UI.IntegrationTests.TestUtils; +using EventLogExpert.UI.IntegrationTests.TestUtils.Constants; using Microsoft.Data.Sqlite; using NSubstitute; using System.IO.Compression; -namespace EventLogExpert.UI.Tests.Database; +namespace EventLogExpert.UI.IntegrationTests.Database; public sealed class DatabaseServiceTests : IDisposable { diff --git a/tests/Unit/EventLogExpert.UI.Tests/DebugLog/DebugLogServiceTests.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs similarity index 99% rename from tests/Unit/EventLogExpert.UI.Tests/DebugLog/DebugLogServiceTests.cs rename to tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs index db8e9a24..1de0eb4a 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DebugLog/DebugLogServiceTests.cs +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs @@ -4,11 +4,11 @@ using EventLogExpert.UI.Common.Files; using EventLogExpert.UI.DebugLog; using EventLogExpert.UI.Settings; -using EventLogExpert.UI.Tests.TestUtils.Constants; +using EventLogExpert.UI.IntegrationTests.TestUtils.Constants; using Microsoft.Extensions.Logging; using NSubstitute; -namespace EventLogExpert.UI.Tests.DebugLog; +namespace EventLogExpert.UI.IntegrationTests.DebugLog; public sealed class DebugLogServiceTests : IDisposable { diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj b/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj new file mode 100644 index 00000000..669ad790 --- /dev/null +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + false + true + + + + + + + + + + + + + + diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/GlobalUsings.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/GlobalUsings.cs new file mode 100644 index 00000000..b04ace3e --- /dev/null +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +global using Xunit; diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.Database.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.Database.cs new file mode 100644 index 00000000..09655316 --- /dev/null +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.Database.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.IntegrationTests.TestUtils.Constants; + +public sealed partial class Constants +{ + // Simple database names (sort: alpha asc) + public const string AnotherDb = "AnotherDb"; + public const string DatabaseA = "Database A"; + public const string DatabaseB = "Database B"; + public const string DatabaseC = "Database C"; + + // Versioned database names (sort: numeric desc) + public const string Server1 = "Server 1"; + public const string Server10 = "Server 10"; + public const string Server2 = "Server 2"; + public const string Server20 = "Server 20"; + public const string SimpleDatabase = "SimpleDatabase"; + public const string Windows10 = "Windows 10"; + public const string Windows11 = "Windows 11"; + + // Test database file names + public const string TestDb1 = "TestDb1.db"; + public const string TestDb2 = "TestDb2.db"; + public const string TestDb3 = "TestDb3.db"; +} diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.DebugLog.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.DebugLog.cs new file mode 100644 index 00000000..ec031bd9 --- /dev/null +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/Constants/Constants.DebugLog.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.IntegrationTests.TestUtils.Constants; + +public sealed partial class Constants +{ + public const string DebugLogDefaultLevelMessage = "Default level message"; + public const string DebugLogErrorMessage = "Error message"; + public const string DebugLogExistingContent = "Existing log content"; + public const string DebugLogFirstMessage = "First message"; + public const string DebugLogLine1 = "Line 1"; + public const string DebugLogLine2 = "Line 2"; + public const string DebugLogLine3 = "Line 3"; + public const string DebugLogNewMessage = "New message"; + public const string DebugLogSecondMessage = "Second message"; + public const string DebugLogTestMessage = "Test message"; + public const string DebugLogThirdMessage = "Third message"; +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/DatabaseSeedUtils.cs similarity index 98% rename from tests/Unit/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs rename to tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/DatabaseSeedUtils.cs index eee2bfeb..00875111 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/TestUtils/DatabaseSeedUtils.cs @@ -5,7 +5,7 @@ using EventLogExpert.Eventing.ProviderDatabase; using Microsoft.Data.Sqlite; -namespace EventLogExpert.UI.Tests.TestUtils; +namespace EventLogExpert.UI.IntegrationTests.TestUtils; internal static class DatabaseSeedUtils { diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json new file mode 100644 index 00000000..77778efd --- /dev/null +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs index cd810326..8cd1fc30 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs @@ -60,4 +60,45 @@ public static void Delete(string? path, int maxAttempts = 10, int delayMs = 200) } } } + + /// + /// Recursively deletes a directory containing SQLite test database files, with retries, + /// after first releasing pooled SqliteConnection handles. + /// + /// + /// Same caveats as : mutates + /// process-wide state, so this helper must only be called from a test assembly configured + /// for serial execution. + /// + public static void DeleteDirectory(string? path, int maxAttempts = 10, int delayMs = 200) + { + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) { return; } + + SqliteConnection.ClearAllPools(); + + for (int i = 0; i < maxAttempts; i++) + { + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (i < maxAttempts - 1) + { + Thread.Sleep(delayMs); + } + catch (UnauthorizedAccessException) when (i < maxAttempts - 1) + { + Thread.Sleep(delayMs); + } + catch (IOException) + { + return; + } + catch (UnauthorizedAccessException) + { + return; + } + } + } } diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj b/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj deleted file mode 100644 index 5fc0482f..00000000 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - false - true - - - - - - - - diff --git a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.Database.cs b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.Database.cs deleted file mode 100644 index 3c7ff8f3..00000000 --- a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.Database.cs +++ /dev/null @@ -1,44 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.UI.Tests.TestUtils.Constants; - -public sealed partial class Constants -{ - public const string AnotherDb = "AnotherDb"; - // Database names - public const string DatabaseA = "Database A"; - public const string DatabaseB = "Database B"; - public const string DatabaseC = "Database C"; - - // Disabled database names - public const string DisabledDb = "DisabledDb"; - public const string DisabledDb1 = "DisabledDb1"; - public const string DisabledDb2 = "DisabledDb2"; - public const string InitialDisabled = "InitialDisabled"; - public const string Linux1 = "Linux 1"; - public const string NewDisabled1 = "NewDisabled1"; - public const string NewDisabled2 = "NewDisabled2"; - public const string Server1 = "Server 1"; - public const string Server10 = "Server 10"; - public const string Server2 = "Server 2"; - public const string Server20 = "Server 20"; - - // Non-versioned database names - public const string SimpleDatabase = "SimpleDatabase"; - - // Test database file names (EnabledDatabaseCollectionProviderTests) - public const string TestDb1 = "TestDb1.db"; - public const string TestDb2 = "TestDb2.db"; - public const string TestDb3 = "TestDb3.db"; - - // Test database full paths - public const string TestDbPath1 = @"C:\Databases\TestDb1.db"; - public const string TestDbPath2 = @"C:\Databases\TestDb2.db"; - public const string TestDbPath3 = @"C:\Databases\TestDb3.db"; - public const string Windows10 = "Windows 10"; - public const string Windows11 = "Windows 11"; - - // Versioned database names - public const string Windows9 = "Windows 9"; -} diff --git a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs index 333ff094..e49df283 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/Constants/Constants.DebugLog.cs @@ -5,13 +5,7 @@ namespace EventLogExpert.UI.Tests.TestUtils.Constants; public sealed partial class Constants { - public const string DebugLogDefaultLevelMessage = "Default level message"; - public const string DebugLogErrorMessage = "Error message"; - public const string DebugLogExistingContent = "Existing log content"; public const string DebugLogFirstMessage = "First message"; - public const string DebugLogLine1 = "Line 1"; - public const string DebugLogLine2 = "Line 2"; - public const string DebugLogLine3 = "Line 3"; public const string DebugLogNewMessage = "New message"; public const string DebugLogOlderTimestamp = "2026-04-29T07:53:19.0000000-04:00"; public const string DebugLogSecondMessage = "Second message"; From 7f08139f6827270a7b91b1e397b92dcc1cd4086f Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 21:13:19 -0500 Subject: [PATCH 3/8] Drop low-value smoke and duplicate tests and assert idempotent dispose contract --- .../Readers/EventLogReaderTests.cs | 51 ++------------ .../Readers/EventLogWatcherTests.cs | 55 --------------- .../SettingsUpgradeProgressBannerTests.cs | 6 +- .../CompressedJsonValueConverterTests.cs | 10 --- .../Providers/EventMessageProviderTests.cs | 70 ------------------- 5 files changed, 8 insertions(+), 184 deletions(-) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs index c99062db..9b08e679 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs @@ -9,16 +9,6 @@ namespace EventLogExpert.Eventing.IntegrationTests.Readers; public sealed class EventLogReaderTests { - [Fact] - public void Constructor_WhenApplicationLog_ShouldNotThrow() - { - // Arrange & Act - using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); - - // Assert - Assert.NotNull(reader); - } - [Fact] public void Constructor_WhenEmptyLogName_ShouldFailToReadEvents() { @@ -50,19 +40,6 @@ public void Constructor_WhenInvalidLog_ShouldFailToReadEvents() Assert.Empty(events); } - [Fact] - public void Constructor_WhenMultipleInstances_ShouldCreateIndependentReaders() - { - // Arrange & Act - using var reader1 = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); - using var reader2 = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); - - // Assert - Assert.NotNull(reader1); - Assert.NotNull(reader2); - Assert.NotSame(reader1, reader2); - } - [Fact] public void Constructor_WhenNullLogName_ShouldFailToReadEvents() { @@ -91,21 +68,13 @@ public void Constructor_WhenPathTypeLogName_ShouldQueryByLogName() Assert.All(events, evt => Assert.Equal(LogPathType.Channel, evt.LogPathType)); } - [Fact] - public void Constructor_WhenRenderXmlFalse_ShouldNotThrow() - { - // Arrange & Act - using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel, renderXml: false); - - // Assert - Assert.NotNull(reader); - } - - [Fact] - public void Constructor_WhenRenderXmlTrue_ShouldNotThrow() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Constructor_WhenRenderXml_ShouldNotThrow(bool renderXml) { // Arrange & Act - using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel, renderXml: true); + using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel, renderXml: renderXml); // Assert Assert.NotNull(reader); @@ -153,16 +122,6 @@ public void Dispose_WhenCalledMultipleTimes_ShouldNotThrow() reader.Dispose(); } - [Fact] - public void Dispose_WhenCalled_ShouldNotThrow() - { - // Arrange - var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); - - // Act & Assert - reader.Dispose(); - } - [Fact] public void EndOfResults_AfterExhaustion_ShouldKeepBookmarkAndNotSetLastErrorCode() { diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs index 64bb3503..92821ddf 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs @@ -25,18 +25,6 @@ public void Constructor_WithCommonLogs_ShouldCreateWatcher(string logName) Assert.False(watcher.Enabled); } - [Fact] - public void Constructor_WithDifferentRenderXmlValues_ShouldCreateWatcher() - { - // Arrange & Act - using var watcherWithXml = new EventLogWatcher(Constants.ApplicationLogName, renderXml: true); - using var watcherWithoutXml = new EventLogWatcher(Constants.ApplicationLogName, renderXml: false); - - // Assert - Assert.NotNull(watcherWithXml); - Assert.NotNull(watcherWithoutXml); - } - [Fact] public void Constructor_WithEmptyLogName_ShouldThrowArgumentException() { @@ -54,49 +42,6 @@ public void Constructor_WithInvalidLogName_ShouldThrowFileNotFoundException() Assert.Throws(() => new EventLogWatcher(invalidLogName)); } - [Fact] - public void Constructor_WithLogNameAndRenderXml_ShouldCreateWatcher() - { - // Arrange & Act - using var watcher = new EventLogWatcher(Constants.ApplicationLogName, renderXml: true); - - // Assert - Assert.NotNull(watcher); - } - - [Fact] - public void Constructor_WithLogNameBookmarkAndRenderXml_ShouldCreateWatcher() - { - // Arrange - string? bookmark = null; - - // Act - using var watcher = new EventLogWatcher(Constants.ApplicationLogName, bookmark, renderXml: false); - - // Assert - Assert.NotNull(watcher); - } - - [Fact] - public void Constructor_WithLogName_ShouldCreateWatcher() - { - // Arrange & Act - using var watcher = new EventLogWatcher(Constants.ApplicationLogName); - - // Assert - Assert.NotNull(watcher); - } - - [Fact] - public void Constructor_WithNullBookmark_ShouldCreateWatcher() - { - // Arrange & Act - using var watcher = new EventLogWatcher(Constants.ApplicationLogName, null, renderXml: false); - - // Assert - Assert.NotNull(watcher); - } - [Fact] public void Constructor_WithNullLogName_ShouldThrowArgumentException() { diff --git a/tests/Unit/EventLogExpert.Components.Tests/Database/SettingsUpgradeProgressBannerTests.cs b/tests/Unit/EventLogExpert.Components.Tests/Database/SettingsUpgradeProgressBannerTests.cs index 42fc3388..c28f1130 100644 --- a/tests/Unit/EventLogExpert.Components.Tests/Database/SettingsUpgradeProgressBannerTests.cs +++ b/tests/Unit/EventLogExpert.Components.Tests/Database/SettingsUpgradeProgressBannerTests.cs @@ -81,11 +81,11 @@ public void SettingsUpgradeProgressBanner_DisposeIsIdempotent() // Arrange var component = Render(); var instance = component.Instance; - - // Act + Assert - instance.Dispose(); instance.Dispose(); + // Act + Assert - second Dispose must not throw + var exception = Record.Exception(() => instance.Dispose()); + Assert.Null(exception); } [Fact] diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs index b7e08408..fb35fe26 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs @@ -10,16 +10,6 @@ namespace EventLogExpert.Eventing.Tests.ProviderDatabase; public sealed class CompressedJsonValueConverterTests { - [Fact] - public void Constructor_ShouldCreateInstance() - { - // Act - var converter = new CompressedJsonValueConverter(); - - // Assert - Assert.NotNull(converter); - } - [Fact] public void ConvertFromCompressedJson_WithEmptyCollection_ShouldReturnEmptyCollection() { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs index 2ef659fe..acdc066a 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs @@ -10,18 +10,6 @@ namespace EventLogExpert.Eventing.Tests.Providers; public sealed class EventMessageProviderTests { - [Theory] - [InlineData(Constants.TestProviderName)] - [InlineData(Constants.TestProviderLongName)] - public void Constructor_WhenDifferentProviderNames_ShouldCreateInstances(string providerName) - { - // Arrange & Act - EventMessageProvider provider = new(providerName); - - // Assert - Assert.NotNull(provider); - } - [Fact] public void LoadMessagesFromFiles_WhenDuplicateFiles_ShouldProcessAll() { @@ -68,19 +56,6 @@ public void LoadMessagesFromFiles_WhenFileListIsNull_ShouldThrowArgumentNullExce EventMessageProvider.LoadMessagesFromFiles(null!, Constants.TestProviderName, mockLogger)); } - [Fact] - public void LoadMessagesFromFiles_WhenFilePathHasMultipleBackslashes_ShouldExtractFileName() - { - // Arrange - var filesWithPath = new[] { Constants.NonExistentDllFullPath }; - - // Act - var messages = EventMessageProvider.LoadMessagesFromFiles(filesWithPath, Constants.TestProviderName); - - // Assert - Assert.NotNull(messages); - } - [Fact] public void LoadMessagesFromFiles_WhenInvalidFile_ShouldLogWarning() { @@ -117,49 +92,4 @@ public void LoadMessagesFromFiles_WhenInvalidFile_ShouldReturnEmptyList() Assert.NotNull(messages); Assert.Empty(messages); } - - [Fact] - public void LoadMessagesFromFiles_WhenMultipleInvalidFiles_ShouldLogMultipleWarnings() - { - // Arrange - var invalidFiles = new[] { Constants.NonExistentDll, Constants.NonExistentDll }; - var mockLogger = Substitute.For(); - - // Act - EventMessageProvider.LoadMessagesFromFiles(invalidFiles, Constants.TestProviderName, mockLogger); - - // Assert: each input that fails the primary MUI-aware load produces a debug log that - // begins with "LoadLibraryEx failed for {file}". Asserting per-input presence (with the - // filename in the message) is robust to future changes in the number of fallback attempts - // or extra diagnostic lines per input — only the primary-attempt failure log is - // contractually guaranteed to fire once per input here. - mockLogger.Received(invalidFiles.Length) - .Debug(Arg.Is(h => - h.ToString().Contains("LoadLibraryEx failed") && - h.ToString().Contains(Constants.NonExistentDll))); - } - - [Fact] - public void LoadMessagesFromFiles_WhenMultipleInvalidFiles_ShouldReturnEmptyList() - { - // Arrange - var invalidFiles = new[] { Constants.NonExistentDll, Constants.NonExistentDll, Constants.NonExistentDll }; - - // Act - var messages = EventMessageProvider.LoadMessagesFromFiles(invalidFiles, Constants.TestProviderName); - - // Assert - Assert.NotNull(messages); - Assert.Empty(messages); - } - - [Fact] - public void LoadMessagesFromFiles_WhenProviderNameProvided_ShouldIncludeInMessages() - { - // Arrange & Act - var messages = EventMessageProvider.LoadMessagesFromFiles([], Constants.TestProviderName); - - // Assert - Assert.NotNull(messages); - } } From 1996d3d82fbaa71e97b519e0d2597ac0096ee28b Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 21:22:54 -0500 Subject: [PATCH 4/8] Parameterize invalid-log-name and cache-kind mirror tests --- .../Readers/EventLogReaderTests.cs | 62 +------ .../Resolvers/EventResolverCacheTests.cs | 172 +++++------------- 2 files changed, 58 insertions(+), 176 deletions(-) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs index 9b08e679..e5b5b169 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs @@ -9,47 +9,20 @@ namespace EventLogExpert.Eventing.IntegrationTests.Readers; public sealed class EventLogReaderTests { - [Fact] - public void Constructor_WhenEmptyLogName_ShouldFailToReadEvents() - { - // Arrange & Act - using var reader = new EventLogReader(string.Empty, LogPathType.Channel); - - // Assert - TryGetEvents must fail - bool success = reader.TryGetEvents(out var events); - - Assert.False(success, "TryGetEvents should return false for empty log name"); - Assert.NotNull(events); - Assert.Empty(events); - } - - [Fact] - public void Constructor_WhenInvalidLog_ShouldFailToReadEvents() - { - // Arrange - var invalidLogName = "NonExistentLog_" + Guid.NewGuid(); - - // Act - using var reader = new EventLogReader(invalidLogName, LogPathType.Channel); - - // Assert - TryGetEvents must fail - bool success = reader.TryGetEvents(out var events); - - Assert.False(success, "TryGetEvents should return false for invalid log name"); - Assert.NotNull(events); - Assert.Empty(events); - } - - [Fact] - public void Constructor_WhenNullLogName_ShouldFailToReadEvents() + [Theory] + [InlineData("", "empty log name")] + [InlineData(null, "null log name")] + [InlineData("Invalid<>Log|Name", "log name with special characters")] + [InlineData("NonExistentLog_TestSentinel_8e9c2b4a", "non-existent log name")] + public void Constructor_WhenInvalidLogName_ShouldFailToReadEvents(string? logName, string scenario) { // Arrange & Act - using var reader = new EventLogReader(null!, LogPathType.Channel); + using var reader = new EventLogReader(logName!, LogPathType.Channel); - // Assert - TryGetEvents must fail + // Assert - TryGetEvents must fail without throwing bool success = reader.TryGetEvents(out var events); - Assert.False(success, "TryGetEvents should return false for null log name"); + Assert.False(success, $"TryGetEvents should return false for {scenario}"); Assert.NotNull(events); Assert.Empty(events); } @@ -80,23 +53,6 @@ public void Constructor_WhenRenderXml_ShouldNotThrow(bool renderXml) Assert.NotNull(reader); } - [Fact] - public void Constructor_WhenSpecialCharactersInLogName_ShouldFailToReadEvents() - { - // Arrange - var invalidLogName = "Invalid<>Log|Name"; - - // Act - using var reader = new EventLogReader(invalidLogName, LogPathType.Channel); - - // Assert - TryGetEvents must fail - bool success = reader.TryGetEvents(out var events); - - Assert.False(success, "TryGetEvents should return false for log name with special characters"); - Assert.NotNull(events); - Assert.Empty(events); - } - [Fact] public void Dispose_AfterDispose_TryGetEventsShouldThrow() { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs index 43ce5dd6..55f01a10 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs @@ -7,6 +7,11 @@ namespace EventLogExpert.Eventing.Tests.Resolvers; public sealed class EventResolverCacheTests { + public enum CacheKind { Description, Value } + + private static string GetOrAdd(EventResolverCache cache, CacheKind kind, string input) => + kind == CacheKind.Description ? cache.GetOrAddDescription(input) : cache.GetOrAddValue(input); + [Fact] public void ClearAll_AfterAddingItems_ShouldClearBothCaches() { @@ -78,25 +83,29 @@ public void ClearAll_WithEmptyCaches_ShouldNotThrow() Assert.Null(exception); } - [Fact] - public void GetOrAddDescription_CalledMultipleTimes_ShouldReturnSameReference() + [Theory] + [InlineData(CacheKind.Description)] + [InlineData(CacheKind.Value)] + public void GetOrAdd_CalledMultipleTimes_ShouldReturnSameReference(CacheKind kind) { // Arrange var cache = new EventResolverCache(); - var description = "Test Description"; + var input = $"Test {kind}"; // Act - var result1 = cache.GetOrAddDescription(description); - var result2 = cache.GetOrAddDescription(description); - var result3 = cache.GetOrAddDescription(description); + var result1 = GetOrAdd(cache, kind, input); + var result2 = GetOrAdd(cache, kind, input); + var result3 = GetOrAdd(cache, kind, input); // Assert Assert.Same(result1, result2); Assert.Same(result2, result3); } - [Fact] - public void GetOrAddDescription_ConcurrentCalls_ShouldHandleThreadSafely() + [Theory] + [InlineData(CacheKind.Description)] + [InlineData(CacheKind.Value)] + public void GetOrAdd_ConcurrentCalls_ShouldHandleThreadSafely(CacheKind kind) { // Arrange var cache = new EventResolverCache(); @@ -105,159 +114,76 @@ public void GetOrAddDescription_ConcurrentCalls_ShouldHandleThreadSafely() // Act Parallel.For(0, 100, i => { - results[i] = cache.GetOrAddDescription($"Description{i % 10}"); + results[i] = GetOrAdd(cache, kind, $"{kind}{i % 10}"); }); // Assert - // Verify that same descriptions share the same reference - for (int i = 0; i < 100; i += 10) + // 100 calls were spread across 10 distinct keys ("{kind}0" .. "{kind}9"), 10 calls per key. + // For each key, every occurrence must be the same reference (de-dup contract under + // contention). The previous loop only stepped i by 10, which meant i % 10 was always 0 + // and only key "{kind}0" was actually validated; iterating each key 0..9 explicitly + // covers all ten distinct cache slots so a regression in any one of them fails the test. + for (int key = 0; key < 10; key++) { - var description = $"Description{i % 10}"; - var firstOccurrence = results[i]; + var expected = results[key]; + Assert.NotNull(expected); - for (int j = i; j < 100; j += 10) + for (int occurrence = 1; occurrence < 10; occurrence++) { - Assert.Same(firstOccurrence, results[j]); + Assert.Same(expected, results[key + (occurrence * 10)]); } } } - [Fact] - public void GetOrAddDescription_WithDifferentDescriptions_ShouldReturnDifferentReferences() + [Theory] + [InlineData(CacheKind.Description, "Description 1", "Description 2")] + [InlineData(CacheKind.Value, "Value 1", "Value 2")] + public void GetOrAdd_WithDifferentInputs_ShouldReturnDifferentReferences(CacheKind kind, string a, string b) { // Arrange var cache = new EventResolverCache(); - var description1 = "Description 1"; - var description2 = "Description 2"; // Act - var result1 = cache.GetOrAddDescription(description1); - var result2 = cache.GetOrAddDescription(description2); + var result1 = GetOrAdd(cache, kind, a); + var result2 = GetOrAdd(cache, kind, b); // Assert Assert.NotSame(result1, result2); - Assert.Equal("Description 1", result1); - Assert.Equal("Description 2", result2); + Assert.Equal(a, result1); + Assert.Equal(b, result2); } - [Fact] - public void GetOrAddDescription_WithEmptyString_ShouldReturnSameEmptyStringReference() + [Theory] + [InlineData(CacheKind.Description)] + [InlineData(CacheKind.Value)] + public void GetOrAdd_WithEmptyString_ShouldReturnSameEmptyStringReference(CacheKind kind) { // Arrange var cache = new EventResolverCache(); // Act - var result1 = cache.GetOrAddDescription(string.Empty); - var result2 = cache.GetOrAddDescription(string.Empty); + var result1 = GetOrAdd(cache, kind, string.Empty); + var result2 = GetOrAdd(cache, kind, string.Empty); // Assert Assert.Same(result1, result2); Assert.Equal(string.Empty, result1); } - [Fact] - public void GetOrAddDescription_WithNewString_ShouldAddToCache() - { - // Arrange - var cache = new EventResolverCache(); - var description = new string("Test".ToCharArray()); // Force new string instance - - // Act - var result = cache.GetOrAddDescription(description); - - // Assert - Assert.Equal(description, result); - } - - [Fact] - public void GetOrAddValue_CalledMultipleTimes_ShouldReturnSameReference() - { - // Arrange - var cache = new EventResolverCache(); - var value = "Test Value"; - - // Act - var result1 = cache.GetOrAddValue(value); - var result2 = cache.GetOrAddValue(value); - var result3 = cache.GetOrAddValue(value); - - // Assert - Assert.Same(result1, result2); - Assert.Same(result2, result3); - } - - [Fact] - public void GetOrAddValue_ConcurrentCalls_ShouldHandleThreadSafely() - { - // Arrange - var cache = new EventResolverCache(); - var results = new string[100]; - - // Act - Parallel.For(0, 100, i => - { - results[i] = cache.GetOrAddValue($"Value{i % 10}"); - }); - - // Assert - // Verify that same values share the same reference - for (int i = 0; i < 100; i += 10) - { - var value = $"Value{i % 10}"; - var firstOccurrence = results[i]; - - for (int j = i; j < 100; j += 10) - { - Assert.Same(firstOccurrence, results[j]); - } - } - } - - [Fact] - public void GetOrAddValue_WithDifferentValues_ShouldReturnDifferentReferences() - { - // Arrange - var cache = new EventResolverCache(); - var value1 = "Value 1"; - var value2 = "Value 2"; - - // Act - var result1 = cache.GetOrAddValue(value1); - var result2 = cache.GetOrAddValue(value2); - - // Assert - Assert.NotSame(result1, result2); - Assert.Equal("Value 1", result1); - Assert.Equal("Value 2", result2); - } - - [Fact] - public void GetOrAddValue_WithEmptyString_ShouldReturnSameEmptyStringReference() - { - // Arrange - var cache = new EventResolverCache(); - - // Act - var result1 = cache.GetOrAddValue(string.Empty); - var result2 = cache.GetOrAddValue(string.Empty); - - // Assert - Assert.Same(result1, result2); - Assert.Equal(string.Empty, result1); - } - - [Fact] - public void GetOrAddValue_WithNewString_ShouldAddToCache() + [Theory] + [InlineData(CacheKind.Description)] + [InlineData(CacheKind.Value)] + public void GetOrAdd_WithNewString_ShouldAddToCache(CacheKind kind) { // Arrange var cache = new EventResolverCache(); - var value = new string("Test".ToCharArray()); // Force new string instance + var input = new string("Test".ToCharArray()); // Force new string instance // Act - var result = cache.GetOrAddValue(value); + var result = GetOrAdd(cache, kind, input); // Assert - Assert.Equal(value, result); + Assert.Equal(input, result); } [Fact] From d5711acda81a060147f3d575dbbc90f9c89183cc Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 21:27:42 -0500 Subject: [PATCH 5/8] Drop weak constructor smoke tests in resolver integration tests --- ...EventProviderDatabaseEventResolverTests.cs | 54 ------------------- .../Resolvers/VersatileEventResolverTests.cs | 24 --------- 2 files changed, 78 deletions(-) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs index b165a1c6..6ca7ecf3 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs @@ -16,22 +16,6 @@ namespace EventLogExpert.Eventing.IntegrationTests.Resolvers; public sealed class EventResolverDatabaseTests { - [Fact] - public void Constructor_WithCacheAndLogger_ShouldCreateInstance() - { - // Arrange - var dbCollection = Substitute.For(); - dbCollection.ActiveDatabases.Returns([]); - var cache = Substitute.For(); - var logger = Substitute.For(); - - // Act - using var resolver = new EventResolver(dbCollection, cache, logger); - - // Assert - Assert.NotNull(resolver); - } - [Fact] public void Constructor_WithLogger_ShouldLogDatabasePaths() { @@ -87,44 +71,6 @@ public void Constructor_WithNonExistentDatabase_ShouldThrowFileNotFoundException Assert.Contains(nonExistentPath, exception.Message); } - [Fact] - public void Constructor_WithNullCache_ShouldCreateInstance() - { - // Arrange - var dbCollection = Substitute.For(); - dbCollection.ActiveDatabases.Returns([]); - - // Act - using var resolver = new EventResolver(dbCollection, null); - - // Assert - Assert.NotNull(resolver); - } - - [Fact] - public void Constructor_WithNullDatabaseCollection_ShouldCreateInstance() - { - // Act - using var resolver = new EventResolver(null); - - // Assert - Assert.NotNull(resolver); - } - - [Fact] - public void Constructor_WithNullLogger_ShouldCreateInstance() - { - // Arrange - var dbCollection = Substitute.For(); - dbCollection.ActiveDatabases.Returns([]); - - // Act - using var resolver = new EventResolver(dbCollection, logger: null); - - // Assert - Assert.NotNull(resolver); - } - [Fact] public void Constructor_WithValidDatabase_ShouldLoadDatabase() { diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs index 1ad4f6fb..57f086be 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/VersatileEventResolverTests.cs @@ -17,20 +17,6 @@ namespace EventLogExpert.Eventing.IntegrationTests.Resolvers; public sealed class EventResolverTests { - [Fact] - public void Constructor_WithCacheAndLogger_ShouldCreateInstance() - { - // Arrange - var cache = new EventResolverCache(); - var logger = Substitute.For(); - - // Act - using var resolver = new EventResolver(cache: cache, logger: logger); - - // Assert - Assert.NotNull(resolver); - } - [Fact] public void Constructor_WithDatabaseCollection_ShouldUseDatabaseResolver() { @@ -86,16 +72,6 @@ public void Constructor_WithLogger_ShouldLogInstantiation() logger.Received().Debug(Arg.Is(h => h.ToString().Contains("EventResolver"))); } - [Fact] - public void Constructor_WithNoDatabaseCollection_ShouldUseLocalResolver() - { - // Act - using var resolver = new EventResolver(null); - - // Assert - Assert.NotNull(resolver); - } - [Fact] public void Dispose_CalledMultipleTimes_ShouldNotThrow() { From 2c71fc1f8aec5cdce87422974d7e801f965c1a2f Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 21:43:10 -0500 Subject: [PATCH 6/8] Add EventDbTool coverage for RegexHelper, MtaProviderSource, and command failure paths --- EventLogExpert.slnx | 1 + .../CreateDatabaseCommand.cs | 2 +- .../Properties/AssemblyInfo.cs | 1 + src/EventLogExpert.EventDbTool/ShowCommand.cs | 2 +- .../CreateDatabaseCommandTests.cs | 280 ++++++++++++++++++ .../MtaProviderSourceTests.cs | 136 +++++++++ .../ShowCommandTests.cs | 117 ++++++++ .../EventLogExpert.EventDbTool.Tests.csproj | 13 + .../GlobalUsings.cs | 4 + .../ProgramTests.cs | 49 +++ .../RegexHelperTests.cs | 89 ++++++ 11 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs create mode 100644 tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs create mode 100644 tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ShowCommandTests.cs create mode 100644 tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj create mode 100644 tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs create mode 100644 tests/Unit/EventLogExpert.EventDbTool.Tests/ProgramTests.cs create mode 100644 tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs diff --git a/EventLogExpert.slnx b/EventLogExpert.slnx index 8fc04935..c9c24a96 100644 --- a/EventLogExpert.slnx +++ b/EventLogExpert.slnx @@ -18,6 +18,7 @@ + diff --git a/src/EventLogExpert.EventDbTool/CreateDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/CreateDatabaseCommand.cs index 95862cee..39fd6561 100644 --- a/src/EventLogExpert.EventDbTool/CreateDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/CreateDatabaseCommand.cs @@ -70,7 +70,7 @@ public static Command GetCommand() return createDatabaseCommand; } - private void CreateDatabase(string path, string? source, string? filter, string? skipProvidersInFile) + internal void CreateDatabase(string path, string? source, string? filter, string? skipProvidersInFile) { if (File.Exists(path)) { diff --git a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs index 9b47f49a..fa5fabc5 100644 --- a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs +++ b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.IntegrationTests")] +[assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.Tests")] diff --git a/src/EventLogExpert.EventDbTool/ShowCommand.cs b/src/EventLogExpert.EventDbTool/ShowCommand.cs index 489bdb1d..95373655 100644 --- a/src/EventLogExpert.EventDbTool/ShowCommand.cs +++ b/src/EventLogExpert.EventDbTool/ShowCommand.cs @@ -52,7 +52,7 @@ public static Command GetCommand() return showCommand; } - private void ShowProviderInfo(string? source, string? filter) + internal void ShowProviderInfo(string? source, string? filter) { if (!RegexHelper.TryCreate(filter, Logger, out var regex)) { return; } diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs new file mode 100644 index 00000000..54b5d469 --- /dev/null +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs @@ -0,0 +1,280 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.Logging; +using EventLogExpert.Eventing.ProviderDatabase; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.IntegrationTests; + +public sealed class CreateDatabaseCommandTests : IDisposable +{ + private readonly List _tempPaths = []; + private readonly List _tempDirs = []; + + public void Dispose() + { + foreach (var path in _tempPaths) + { + DatabaseTestUtils.DeleteDatabaseFile(path); + } + + foreach (var dir in _tempDirs) + { + DatabaseTestUtils.DeleteDirectoryRecursive(dir); + } + } + + [Fact] + public void CreateDatabase_WhenTargetFileAlreadyExists_LogsErrorAndDoesNotOverwrite() + { + // Arrange — target file already exists with sentinel bytes; the command must not overwrite or truncate. + var path = CreateTempPath(); + var sentinel = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + File.WriteAllBytes(path, sentinel); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: null, skipProvidersInFile: null); + + // Assert + Assert.Equal(sentinel, File.ReadAllBytes(path)); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("file already exists") && h.ToString().Contains(path))); + } + + [Fact] + public void CreateDatabase_WhenExtensionNotDb_LogsErrorAndDoesNotCreateFile() + { + // Arrange + var path = DatabaseTestUtils.CreateTempPath(".txt"); + _tempPaths.Add(path); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: null, skipProvidersInFile: null); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the extension is wrong."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("File extension must be .db"))); + } + + [Fact] + public void CreateDatabase_WhenFilterRegexInvalid_LogsErrorAndDoesNotCreateFile() + { + // Arrange + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: "[unclosed", skipProvidersInFile: null); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the filter is invalid."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Invalid --filter regex"))); + } + + [Fact] + public void CreateDatabase_WhenSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + { + // Arrange + var path = CreateTempPath(); + var missingSource = DatabaseTestUtils.CreateTempPath(".db"); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source: missingSource, filter: null, skipProvidersInFile: null); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the source is missing."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Source not found") && h.ToString().Contains(missingSource))); + } + + [Fact] + public void CreateDatabase_WhenSkipProvidersInFileSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + { + // Arrange — valid source but invalid skip-source. Validation order matters: a missing skip + // file must fail BEFORE we begin writing the output database, so an empty stub is never + // left behind. + var path = CreateTempPath(); + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + + var missingSkipSource = DatabaseTestUtils.CreateTempPath(".db"); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: missingSkipSource); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the skip-source is missing."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Source not found") && h.ToString().Contains(missingSkipSource))); + } + + [Fact] + public void CreateDatabase_WhenFilterMatchesNoProviders_DoesNotLeaveEmptyDatabaseOnDisk() + { + // Arrange — source has First+Second, filter excludes both. The command must not leave an + // empty .db file behind, because a downstream consumer would read it as "this collection + // has zero providers" instead of "this provider set was never collected". + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: "ZZZ_NoMatch_ZZZ", skipProvidersInFile: null); + + // Assert + Assert.False(File.Exists(path), "No file should be written when zero providers were resolved."); + logger.Received(1).Warning(Arg.Is(h => + h.ToString().Contains("No provider details could be resolved") && + h.ToString().Contains("Database was not created"))); + // No errors should have been logged on this path; an extra error here would be a UX regression. + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void CreateDatabase_WhenSkipProvidersInFileExcludesAll_DoesNotLeaveEmptyDatabaseOnDisk() + { + // Arrange — distinct contract path from the filter case: the skip-source contains every + // provider in the source, leaving zero to write. The "no empty .db" guarantee must hold + // here too; a regression in the skip-set integration could reintroduce the empty stub. + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + + var skipSource = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(skipSource, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: skipSource); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the skip-set excludes all providers."); + logger.Received(1).Warning(Arg.Is(h => + h.ToString().Contains("No provider details could be resolved") && + h.ToString().Contains("Database was not created"))); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void CreateDatabase_WhenSourceProvidersResolved_PersistsAllProvidersAndPreservesOwningPublisher() + { + // Arrange — one provider has ResolvedFromOwningPublisher set; this round-trips through the + // full streaming write path and we verify the persisted DB matches the source contents. + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName, Constants.OwningPublisherName)); + + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: null); + + // Assert + Assert.True(File.Exists(path), "Output database should be created when providers are resolved."); + + using var verify = new ProviderDbContext(path, true); + var rows = verify.ProviderDetails.OrderBy(r => r.ProviderName).ToList(); + Assert.Equal(2, rows.Count); + Assert.Equal(Constants.FirstProviderName, rows[0].ProviderName); + Assert.Null(rows[0].ResolvedFromOwningPublisher); + Assert.Equal(Constants.SecondProviderName, rows[1].ProviderName); + Assert.Equal(Constants.OwningPublisherName, rows[1].ResolvedFromOwningPublisher); + // Success path must not surface any errors or warnings; a regression that warned spuriously + // (e.g., "no providers resolved" reaching the success branch) would degrade operator trust. + logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + [Fact] + public void CreateDatabase_WhenProviderCountCrossesBatchSize_PersistsEveryProviderWithoutErrors() + { + // Arrange — exercises the mid-stream FlushHeaderAndBuffer path: at 100 providers the buffer + // is flushed, the DbContext is materialized, and subsequent providers are appended in + // BatchSize=100 chunks. 101 providers guarantees we cross the boundary AND continue beyond + // it, so a regression in the "after first flush" branch would lose providers. + const int ProviderCount = 101; + var source = CreateTempPath(); + var providers = Enumerable.Range(0, ProviderCount) + .Select(i => DatabaseTestUtils.BuildProviderDetails($"Provider-{i:D4}")) + .ToArray(); + DatabaseTestUtils.CreateV4Database(source, providers); + + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: null); + + // Assert + Assert.True(File.Exists(path)); + + using var verify = new ProviderDbContext(path, true); + Assert.Equal(ProviderCount, verify.ProviderDetails.Count()); + var firstName = verify.ProviderDetails.OrderBy(r => r.ProviderName).First().ProviderName; + var lastName = verify.ProviderDetails.OrderBy(r => r.ProviderName).Last().ProviderName; + Assert.Equal("Provider-0000", firstName); + Assert.Equal($"Provider-{ProviderCount - 1:D4}", lastName); + logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + [Fact] + public void CreateDatabase_WhenSkipProvidersInFileResolves_ExcludesThoseProvidersFromOutput() + { + // Arrange — source has First+Second+Shared. Skip-source has Shared. Output must contain + // First+Second only, exercising the skip-set integration with the streaming write path. + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); + + var skipSource = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(skipSource, + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); + + var path = CreateTempPath(); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: skipSource); + + // Assert + Assert.True(File.Exists(path)); + + using var verify = new ProviderDbContext(path, true); + var names = verify.ProviderDetails.Select(r => r.ProviderName).OrderBy(n => n).ToList(); + Assert.Equal(new[] { Constants.FirstProviderName, Constants.SecondProviderName }, names); + logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + private string CreateTempPath() + { + var path = DatabaseTestUtils.CreateTempPath(); + _tempPaths.Add(path); + + return path; + } +} diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs new file mode 100644 index 00000000..7767d355 --- /dev/null +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs @@ -0,0 +1,136 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.Eventing.Logging; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.IntegrationTests; + +public sealed class MtaProviderSourceTests : IDisposable +{ + private readonly List _tempDirs = []; + private readonly List _tempFiles = []; + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + DatabaseTestUtils.DeleteDirectoryRecursive(dir); + } + + foreach (var file in _tempFiles) + { + DatabaseTestUtils.DeleteDatabaseFile(file); + } + } + + [Fact] + public void DiscoverProviderNames_WhenEvtxFileMissing_LogsErrorReturnsEmpty() + { + // Arrange + var missing = DatabaseTestUtils.CreateTempPath(".evtx"); + var logger = Substitute.For(); + + // Act + var providers = MtaProviderSource.DiscoverProviderNames(missing, logger); + + // Assert + Assert.Empty(providers); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Evtx file not found") && h.ToString().Contains(missing))); + } + + [Fact] + public void DiscoverProviderNames_WhenFilterRegexIsInvalid_LogsErrorReturnsEmpty() + { + // Arrange + var missing = DatabaseTestUtils.CreateTempPath(".evtx"); + var logger = Substitute.For(); + + // Act — invalid pattern is rejected before the evtx is even opened. + var providers = MtaProviderSource.DiscoverProviderNames(missing, logger, filter: "[unclosed"); + + // Assert + Assert.Empty(providers); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Invalid --filter regex"))); + // No "Evtx file not found" should be logged because we short-circuited on the bad regex. + logger.DidNotReceive().Error(Arg.Is(h => + h.ToString().Contains("Evtx file not found"))); + } + + [Fact] + public void FindMtaFiles_WhenLocaleMetaDataDirectoryMissing_LogsErrorReturnsEmpty() + { + // Arrange — create a temp dir with an evtx file but NO LocaleMetaData subdir. + var dir = DatabaseTestUtils.CreateTempDirectory(); + _tempDirs.Add(dir); + var evtxPath = Path.Combine(dir, "test.evtx"); + File.WriteAllBytes(evtxPath, []); + var logger = Substitute.For(); + + // Act + var files = MtaProviderSource.FindMtaFiles(evtxPath, logger); + + // Assert + Assert.Empty(files); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("No LocaleMetaData folder") && h.ToString().Contains(evtxPath))); + } + + [Fact] + public void FindMtaFiles_WhenLocaleMetaDataDirectoryIsEmpty_LogsErrorReturnsEmpty() + { + // Arrange + var dir = DatabaseTestUtils.CreateTempDirectory(); + _tempDirs.Add(dir); + var evtxPath = Path.Combine(dir, "test.evtx"); + File.WriteAllBytes(evtxPath, []); + var localeDir = Path.Combine(dir, "LocaleMetaData"); + Directory.CreateDirectory(localeDir); + var logger = Substitute.For(); + + // Act + var files = MtaProviderSource.FindMtaFiles(evtxPath, logger); + + // Assert + Assert.Empty(files); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("contains no MTA files") && h.ToString().Contains(localeDir))); + } + + [Fact] + public void FindMtaFiles_WhenLocaleMetaDataContainsMtaFiles_ReturnsAllOrdinalSortedAndLogsCount() + { + // Arrange + var dir = DatabaseTestUtils.CreateTempDirectory(); + _tempDirs.Add(dir); + var evtxPath = Path.Combine(dir, "test.evtx"); + File.WriteAllBytes(evtxPath, []); + var localeDir = Path.Combine(dir, "LocaleMetaData"); + Directory.CreateDirectory(localeDir); + + // Names chosen so non-ordinal sorts (e.g., culture-aware) would reorder them differently. + var b = Path.Combine(localeDir, "B-Provider.MTA"); + var a = Path.Combine(localeDir, "A-Provider.MTA"); + var c = Path.Combine(localeDir, "C-Provider.MTA"); + File.WriteAllBytes(a, [0x00]); + File.WriteAllBytes(b, [0x00]); + File.WriteAllBytes(c, [0x00]); + + var logger = Substitute.For(); + + // Act + var files = MtaProviderSource.FindMtaFiles(evtxPath, logger); + + // Assert — ordinal order ensures consistent provider lookup precedence across locales. + Assert.Equal(3, files.Count); + Assert.Equal(a, files[0]); + Assert.Equal(b, files[1]); + Assert.Equal(c, files[2]); + logger.Received(1).Information(Arg.Is(h => + h.ToString().Contains("3 locale metadata file") && h.ToString().Contains(localeDir))); + logger.DidNotReceive().Error(Arg.Any()); + } +} diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ShowCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ShowCommandTests.cs new file mode 100644 index 00000000..a051ffc7 --- /dev/null +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/ShowCommandTests.cs @@ -0,0 +1,117 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils; +using EventLogExpert.EventDbTool.IntegrationTests.TestUtils.Constants; +using EventLogExpert.Eventing.Logging; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.IntegrationTests; + +public sealed class ShowCommandTests : IDisposable +{ + private readonly List _tempPaths = []; + + public void Dispose() + { + foreach (var path in _tempPaths) + { + DatabaseTestUtils.DeleteDatabaseFile(path); + } + } + + [Fact] + public void ShowProviderInfo_WhenFilterRegexInvalid_LogsErrorAndReturns() + { + // Arrange — pass a source so we never hit the local-machine path (non-deterministic). + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + + var logger = Substitute.For(); + + // Act + new ShowCommand(logger).ShowProviderInfo(source, filter: "[unclosed"); + + // Assert + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Invalid --filter regex"))); + logger.DidNotReceive().Information(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + [Fact] + public void ShowProviderInfo_WhenSourceDoesNotExist_LogsErrorAndDoesNotReadAnything() + { + // Arrange + var missing = DatabaseTestUtils.CreateTempPath(".db"); + var logger = Substitute.For(); + + // Act + new ShowCommand(logger).ShowProviderInfo(missing, filter: null); + + // Assert + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Source not found") && h.ToString().Contains(missing))); + logger.DidNotReceive().Information(Arg.Any()); + } + + [Fact] + public void ShowProviderInfo_WhenSourceFilterMatchesNoProviders_LogsNoProvidersWarningOnly() + { + // Arrange — source has providers but the filter matches none. The "no providers found" + // message is the contract for "your filter is too narrow" UX, distinct from "source missing". + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + + var logger = Substitute.For(); + + // Act + new ShowCommand(logger).ShowProviderInfo(source, filter: "ZZZ_NoMatch_ZZZ"); + + // Assert + logger.Received(1).Warning(Arg.Is(h => + h.ToString().Contains("No providers found"))); + logger.DidNotReceive().Error(Arg.Any()); + // No detail header should be emitted when there is nothing to list. + logger.DidNotReceive().Information(Arg.Is(h => + h.ToString().Contains("Provider Name"))); + } + + [Fact] + public void ShowProviderInfo_WhenSourceHasProviders_LogsHeaderAndOneRowPerProvider() + { + // Arrange — two providers. We assert that the header appears exactly once and a row for + // each provider name appears, locking in the streaming "header + per-provider row" output + // contract that downstream tooling and humans both depend on. + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + + var logger = Substitute.For(); + + // Act + new ShowCommand(logger).ShowProviderInfo(source, filter: null); + + // Assert + logger.Received(1).Information(Arg.Is(h => + h.ToString().Contains("Provider Name") && h.ToString().Contains("Events"))); + logger.Received(1).Information(Arg.Is(h => + h.ToString().Contains(Constants.FirstProviderName))); + logger.Received(1).Information(Arg.Is(h => + h.ToString().Contains(Constants.SecondProviderName))); + logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + private string CreateTempPath() + { + var path = DatabaseTestUtils.CreateTempPath(); + _tempPaths.Add(path); + + return path; + } +} diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj b/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj new file mode 100644 index 00000000..5fc0482f --- /dev/null +++ b/tests/Unit/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj @@ -0,0 +1,13 @@ + + + + false + true + + + + + + + + diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs b/tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs new file mode 100644 index 00000000..b04ace3e --- /dev/null +++ b/tests/Unit/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +global using Xunit; diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/ProgramTests.cs b/tests/Unit/EventLogExpert.EventDbTool.Tests/ProgramTests.cs new file mode 100644 index 00000000..90ba9271 --- /dev/null +++ b/tests/Unit/EventLogExpert.EventDbTool.Tests/ProgramTests.cs @@ -0,0 +1,49 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.EventDbTool.Tests; + +public sealed class ProgramTests +{ + [Fact] + public void BuildServiceProvider_RegistersITraceLoggerAsSingleton() + { + // Arrange / Act — the same provider should hand out the same logger instance for repeated + // resolves, which is the behavior the rest of the tool depends on (a per-call logger would + // double-buffer trace output and lose verbose-level config). + using var sp = Program.BuildServiceProvider(verbose: false); + + var first = sp.GetRequiredService(); + var second = sp.GetRequiredService(); + + // Assert + Assert.Same(first, second); + } + + [Fact] + public void BuildServiceProvider_WhenVerboseFalse_RegistersLoggerAtInformationLevel() + { + // Default CLI verbosity is Information so users see progress but not internal trace noise. + using var sp = Program.BuildServiceProvider(verbose: false); + + var logger = sp.GetRequiredService(); + + Assert.Equal(LogLevel.Information, logger.MinimumLevel); + } + + [Fact] + public void BuildServiceProvider_WhenVerboseTrue_RegistersLoggerAtTraceLevel() + { + // The --verbose flag is the only way to surface Trace/Debug logs, so the contract that + // verbose=true ⇒ MinimumLevel=Trace is load-bearing for troubleshooting workflows. + using var sp = Program.BuildServiceProvider(verbose: true); + + var logger = sp.GetRequiredService(); + + Assert.Equal(LogLevel.Trace, logger.MinimumLevel); + } +} diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs b/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs new file mode 100644 index 00000000..64ae387a --- /dev/null +++ b/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs @@ -0,0 +1,89 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Logging; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.Tests; + +public sealed class RegexHelperTests +{ + [Fact] + public void TryCreate_WhenPatternIsEmpty_ReturnsTrueAndNoRegex() + { + // Arrange + var logger = Substitute.For(); + + // Act + var success = RegexHelper.TryCreate(string.Empty, logger, out var regex); + + // Assert — empty pattern means "no filter", caller must distinguish from a malformed one. + Assert.True(success); + Assert.Null(regex); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void TryCreate_WhenPatternIsNull_ReturnsTrueAndNoRegex() + { + // Arrange + var logger = Substitute.For(); + + // Act + var success = RegexHelper.TryCreate(null, logger, out var regex); + + // Assert + Assert.True(success); + Assert.Null(regex); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void TryCreate_WhenPatternIsValid_ReturnsTrueAndCaseInsensitiveRegex() + { + // Arrange + var logger = Substitute.For(); + + // Act + var success = RegexHelper.TryCreate("microsoft-windows-.*", logger, out var regex); + + // Assert — case insensitivity is contractual: provider names like "Microsoft-Windows-AAD" must match. + Assert.True(success); + Assert.NotNull(regex); + Assert.Matches(regex, "Microsoft-Windows-AAD"); + Assert.Matches(regex, "MICROSOFT-WINDOWS-FOO"); + Assert.DoesNotMatch(regex, "OpenSSH"); + } + + [Fact] + public void TryCreate_WhenPatternIsValid_HasOneSecondMatchTimeout() + { + // Arrange + var logger = Substitute.For(); + + // Act + RegexHelper.TryCreate("foo", logger, out var regex); + + // Assert — the timeout bound prevents catastrophic-backtracking patterns from hanging the + // process. Pinning the exact 1-second value ensures a regression that quietly bumped this + // to InfiniteMatchTimeout (or to many minutes) would fail this test. + Assert.NotNull(regex); + Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout); + } + + [Fact] + public void TryCreate_WhenPatternIsInvalid_ReturnsFalseLogsErrorAndYieldsNullRegex() + { + // Arrange — unbalanced character class is a parse-time ArgumentException. + var logger = Substitute.For(); + + // Act + var success = RegexHelper.TryCreate("[unclosed", logger, out var regex); + + // Assert + Assert.False(success); + Assert.Null(regex); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Invalid --filter regex") && h.ToString().Contains("[unclosed"))); + } +} From bc436d1b39789552fc1ab2c9dea5ddff635d6592 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 11 May 2026 22:17:03 -0500 Subject: [PATCH 7/8] Move OS-API-dependent provider and resolver tests to Integration project --- .../Interop/NativeErrorResolverTests.cs | 2 +- .../Providers/EventMessageProviderTests.cs | 2 +- .../Resolvers/LocalProviderEventResolverTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/Interop/NativeErrorResolverTests.cs (97%) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/Providers/EventMessageProviderTests.cs (98%) rename tests/{Unit/EventLogExpert.Eventing.Tests => Integration/EventLogExpert.Eventing.IntegrationTests}/Resolvers/LocalProviderEventResolverTests.cs (99%) diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeErrorResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Interop/NativeErrorResolverTests.cs similarity index 97% rename from tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeErrorResolverTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/Interop/NativeErrorResolverTests.cs index c50f9fcf..d681618c 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeErrorResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Interop/NativeErrorResolverTests.cs @@ -3,7 +3,7 @@ using EventLogExpert.Eventing.Interop; -namespace EventLogExpert.Eventing.Tests.Interop; +namespace EventLogExpert.Eventing.IntegrationTests.Interop; public sealed class NativeErrorResolverTests { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderTests.cs similarity index 98% rename from tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderTests.cs index acdc066a..be55c1d5 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Providers/EventMessageProviderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderTests.cs @@ -6,7 +6,7 @@ using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; -namespace EventLogExpert.Eventing.Tests.Providers; +namespace EventLogExpert.Eventing.IntegrationTests.Providers; public sealed class EventMessageProviderTests { diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs similarity index 99% rename from tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs index 977ecf6b..ed3787ad 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/LocalProviderEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs @@ -9,7 +9,7 @@ using NSubstitute; using System.Collections.Concurrent; -namespace EventLogExpert.Eventing.Tests.Resolvers; +namespace EventLogExpert.Eventing.IntegrationTests.Resolvers; public sealed class EventResolverLocalProviderTests { From f19e1b352e469204f5d3825908d9f6c2c2d04cd5 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Tue, 12 May 2026 09:36:54 -0500 Subject: [PATCH 8/8] Replace null-forgiving operator with explicit null assertions in tests --- .../CreateDatabaseCommandTests.cs | 246 ++++++------ .../MtaProviderSourceTests.cs | 80 ++-- .../ProviderDbContextTests.cs | 44 +-- .../EventMessageProviderIntegrationTests.cs | 40 +- .../Providers/ProviderMetadataTests.cs | 29 +- .../Providers/RegistryProviderTests.cs | 42 +- .../Readers/EventLogInformationTests.cs | 2 +- .../Readers/EventLogReaderTests.cs | 24 +- .../Readers/EventLogSessionTests.cs | 100 ++--- .../Readers/EventLogWatcherTests.cs | 82 ++-- ...EventProviderDatabaseEventResolverTests.cs | 22 +- .../LocalProviderEventResolverTests.cs | 46 +-- .../Database/DatabaseServiceTests.cs | 68 ++-- .../DebugLog/DebugLogServiceTests.cs | 67 ++-- .../SqliteTestDb.cs | 40 +- .../BannerHostTests.cs | 20 +- .../Database/DatabaseEntryRowTests.cs | 22 +- .../Modals/DebugLogModalTests.cs | 123 +++--- .../RegexHelperTests.cs | 40 +- .../Common/Channels/LogChannelMethodsTests.cs | 12 +- .../Interop/NativeMethodsEvtTests.cs | 124 +++--- .../CompressedJsonValueConverterTests.cs | 3 +- .../ProviderJsonContextTests.cs | 8 +- .../Resolvers/EventResolverBaseTests.cs | 366 +++++++++--------- .../Resolvers/EventResolverCacheTests.cs | 6 +- .../Alerts/AlertDialogServiceTests.cs | 50 +-- .../Banner/BannerServiceTests.cs | 12 +- .../EventLog/EffectsTests.cs | 2 +- 28 files changed, 872 insertions(+), 848 deletions(-) diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs index 54b5d469..217608e1 100644 --- a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/CreateDatabaseCommandTests.cs @@ -11,39 +11,8 @@ namespace EventLogExpert.EventDbTool.IntegrationTests; public sealed class CreateDatabaseCommandTests : IDisposable { - private readonly List _tempPaths = []; private readonly List _tempDirs = []; - - public void Dispose() - { - foreach (var path in _tempPaths) - { - DatabaseTestUtils.DeleteDatabaseFile(path); - } - - foreach (var dir in _tempDirs) - { - DatabaseTestUtils.DeleteDirectoryRecursive(dir); - } - } - - [Fact] - public void CreateDatabase_WhenTargetFileAlreadyExists_LogsErrorAndDoesNotOverwrite() - { - // Arrange — target file already exists with sentinel bytes; the command must not overwrite or truncate. - var path = CreateTempPath(); - var sentinel = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; - File.WriteAllBytes(path, sentinel); - var logger = Substitute.For(); - - // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: null, skipProvidersInFile: null); - - // Assert - Assert.Equal(sentinel, File.ReadAllBytes(path)); - logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("file already exists") && h.ToString().Contains(path))); - } + private readonly List _tempPaths = []; [Fact] public void CreateDatabase_WhenExtensionNotDb_LogsErrorAndDoesNotCreateFile() @@ -63,102 +32,124 @@ public void CreateDatabase_WhenExtensionNotDb_LogsErrorAndDoesNotCreateFile() } [Fact] - public void CreateDatabase_WhenFilterRegexInvalid_LogsErrorAndDoesNotCreateFile() + public void CreateDatabase_WhenFilterMatchesNoProviders_DoesNotLeaveEmptyDatabaseOnDisk() { - // Arrange + // Arrange — source has First+Second, filter excludes both. The command must not leave an + // empty .db file behind, because a downstream consumer would read it as "this collection + // has zero providers" instead of "this provider set was never collected". + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + var path = CreateTempPath(); var logger = Substitute.For(); // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: "[unclosed", skipProvidersInFile: null); + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: "ZZZ_NoMatch_ZZZ", skipProvidersInFile: null); // Assert - Assert.False(File.Exists(path), "No file should be written when the filter is invalid."); - logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("Invalid --filter regex"))); + Assert.False(File.Exists(path), "No file should be written when zero providers were resolved."); + logger.Received(1).Warning(Arg.Is(h => + h.ToString().Contains("No provider details could be resolved") && + h.ToString().Contains("Database was not created"))); + // No errors should have been logged on this path; an extra error here would be a UX regression. + logger.DidNotReceive().Error(Arg.Any()); } [Fact] - public void CreateDatabase_WhenSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + public void CreateDatabase_WhenFilterRegexInvalid_LogsErrorAndDoesNotCreateFile() { // Arrange var path = CreateTempPath(); - var missingSource = DatabaseTestUtils.CreateTempPath(".db"); var logger = Substitute.For(); // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source: missingSource, filter: null, skipProvidersInFile: null); + new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: "[unclosed", skipProvidersInFile: null); // Assert - Assert.False(File.Exists(path), "No file should be written when the source is missing."); + Assert.False(File.Exists(path), "No file should be written when the filter is invalid."); logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("Source not found") && h.ToString().Contains(missingSource))); + h.ToString().Contains("Invalid --filter regex"))); } [Fact] - public void CreateDatabase_WhenSkipProvidersInFileSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + public void CreateDatabase_WhenProviderCountCrossesBatchSize_PersistsEveryProviderWithoutErrors() { - // Arrange — valid source but invalid skip-source. Validation order matters: a missing skip - // file must fail BEFORE we begin writing the output database, so an empty stub is never - // left behind. - var path = CreateTempPath(); + // Arrange — exercises the mid-stream FlushHeaderAndBuffer path: at 100 providers the buffer + // is flushed, the DbContext is materialized, and subsequent providers are appended in + // BatchSize=100 chunks. 101 providers guarantees we cross the boundary AND continue beyond + // it, so a regression in the "after first flush" branch would lose providers. + const int ProviderCount = 101; var source = CreateTempPath(); - DatabaseTestUtils.CreateV4Database(source, - DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + var providers = Enumerable.Range(0, ProviderCount) + .Select(i => DatabaseTestUtils.BuildProviderDetails($"Provider-{i:D4}")) + .ToArray(); + DatabaseTestUtils.CreateV4Database(source, providers); - var missingSkipSource = DatabaseTestUtils.CreateTempPath(".db"); + var path = CreateTempPath(); var logger = Substitute.For(); // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: missingSkipSource); + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: null); // Assert - Assert.False(File.Exists(path), "No file should be written when the skip-source is missing."); - logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("Source not found") && h.ToString().Contains(missingSkipSource))); + Assert.True(File.Exists(path)); + + using var verify = new ProviderDbContext(path, true); + Assert.Equal(ProviderCount, verify.ProviderDetails.Count()); + var firstName = verify.ProviderDetails.OrderBy(r => r.ProviderName).First().ProviderName; + var lastName = verify.ProviderDetails.OrderBy(r => r.ProviderName).Last().ProviderName; + Assert.Equal("Provider-0000", firstName); + Assert.Equal($"Provider-{ProviderCount - 1:D4}", lastName); + logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); } [Fact] - public void CreateDatabase_WhenFilterMatchesNoProviders_DoesNotLeaveEmptyDatabaseOnDisk() + public void CreateDatabase_WhenSkipProvidersInFileExcludesAll_DoesNotLeaveEmptyDatabaseOnDisk() { - // Arrange — source has First+Second, filter excludes both. The command must not leave an - // empty .db file behind, because a downstream consumer would read it as "this collection - // has zero providers" instead of "this provider set was never collected". + // Arrange — distinct contract path from the filter case: the skip-source contains every + // provider in the source, leaving zero to write. The "no empty .db" guarantee must hold + // here too; a regression in the skip-set integration could reintroduce the empty stub. var source = CreateTempPath(); DatabaseTestUtils.CreateV4Database(source, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + var skipSource = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(skipSource, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + var path = CreateTempPath(); var logger = Substitute.For(); // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: "ZZZ_NoMatch_ZZZ", skipProvidersInFile: null); + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: skipSource); // Assert - Assert.False(File.Exists(path), "No file should be written when zero providers were resolved."); + Assert.False(File.Exists(path), "No file should be written when the skip-set excludes all providers."); logger.Received(1).Warning(Arg.Is(h => h.ToString().Contains("No provider details could be resolved") && h.ToString().Contains("Database was not created"))); - // No errors should have been logged on this path; an extra error here would be a UX regression. logger.DidNotReceive().Error(Arg.Any()); } [Fact] - public void CreateDatabase_WhenSkipProvidersInFileExcludesAll_DoesNotLeaveEmptyDatabaseOnDisk() + public void CreateDatabase_WhenSkipProvidersInFileResolves_ExcludesThoseProvidersFromOutput() { - // Arrange — distinct contract path from the filter case: the skip-source contains every - // provider in the source, leaving zero to write. The "no empty .db" guarantee must hold - // here too; a regression in the skip-set integration could reintroduce the empty stub. + // Arrange — source has First+Second+Shared. Skip-source has Shared. Output must contain + // First+Second only, exercising the skip-set integration with the streaming write path. var source = CreateTempPath(); DatabaseTestUtils.CreateV4Database(source, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), - DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); var skipSource = CreateTempPath(); DatabaseTestUtils.CreateV4Database(skipSource, - DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), - DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); var path = CreateTempPath(); var logger = Substitute.For(); @@ -167,11 +158,53 @@ public void CreateDatabase_WhenSkipProvidersInFileExcludesAll_DoesNotLeaveEmptyD new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: skipSource); // Assert - Assert.False(File.Exists(path), "No file should be written when the skip-set excludes all providers."); - logger.Received(1).Warning(Arg.Is(h => - h.ToString().Contains("No provider details could be resolved") && - h.ToString().Contains("Database was not created"))); + Assert.True(File.Exists(path)); + + using var verify = new ProviderDbContext(path, true); + var names = verify.ProviderDetails.Select(r => r.ProviderName).OrderBy(n => n).ToList(); + Assert.Equal(new[] { Constants.FirstProviderName, Constants.SecondProviderName }, names); logger.DidNotReceive().Error(Arg.Any()); + logger.DidNotReceive().Warning(Arg.Any()); + } + + [Fact] + public void CreateDatabase_WhenSkipProvidersInFileSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + { + // Arrange — valid source but invalid skip-source. Validation order matters: a missing skip + // file must fail BEFORE we begin writing the output database, so an empty stub is never + // left behind. + var path = CreateTempPath(); + var source = CreateTempPath(); + DatabaseTestUtils.CreateV4Database(source, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + + var missingSkipSource = DatabaseTestUtils.CreateTempPath(".db"); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: missingSkipSource); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the skip-source is missing."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Source not found") && h.ToString().Contains(missingSkipSource))); + } + + [Fact] + public void CreateDatabase_WhenSourceDoesNotExist_LogsErrorAndDoesNotCreateFile() + { + // Arrange + var path = CreateTempPath(); + var missingSource = DatabaseTestUtils.CreateTempPath(".db"); + var logger = Substitute.For(); + + // Act + new CreateDatabaseCommand(logger).CreateDatabase(path, source: missingSource, filter: null, skipProvidersInFile: null); + + // Assert + Assert.False(File.Exists(path), "No file should be written when the source is missing."); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Source not found") && h.ToString().Contains(missingSource))); } [Fact] @@ -207,67 +240,34 @@ public void CreateDatabase_WhenSourceProvidersResolved_PersistsAllProvidersAndPr } [Fact] - public void CreateDatabase_WhenProviderCountCrossesBatchSize_PersistsEveryProviderWithoutErrors() + public void CreateDatabase_WhenTargetFileAlreadyExists_LogsErrorAndDoesNotOverwrite() { - // Arrange — exercises the mid-stream FlushHeaderAndBuffer path: at 100 providers the buffer - // is flushed, the DbContext is materialized, and subsequent providers are appended in - // BatchSize=100 chunks. 101 providers guarantees we cross the boundary AND continue beyond - // it, so a regression in the "after first flush" branch would lose providers. - const int ProviderCount = 101; - var source = CreateTempPath(); - var providers = Enumerable.Range(0, ProviderCount) - .Select(i => DatabaseTestUtils.BuildProviderDetails($"Provider-{i:D4}")) - .ToArray(); - DatabaseTestUtils.CreateV4Database(source, providers); - + // Arrange — target file already exists with sentinel bytes; the command must not overwrite or truncate. var path = CreateTempPath(); + var sentinel = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + File.WriteAllBytes(path, sentinel); var logger = Substitute.For(); // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: null); + new CreateDatabaseCommand(logger).CreateDatabase(path, source: null, filter: null, skipProvidersInFile: null); // Assert - Assert.True(File.Exists(path)); - - using var verify = new ProviderDbContext(path, true); - Assert.Equal(ProviderCount, verify.ProviderDetails.Count()); - var firstName = verify.ProviderDetails.OrderBy(r => r.ProviderName).First().ProviderName; - var lastName = verify.ProviderDetails.OrderBy(r => r.ProviderName).Last().ProviderName; - Assert.Equal("Provider-0000", firstName); - Assert.Equal($"Provider-{ProviderCount - 1:D4}", lastName); - logger.DidNotReceive().Error(Arg.Any()); - logger.DidNotReceive().Warning(Arg.Any()); + Assert.Equal(sentinel, File.ReadAllBytes(path)); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("file already exists") && h.ToString().Contains(path))); } - [Fact] - public void CreateDatabase_WhenSkipProvidersInFileResolves_ExcludesThoseProvidersFromOutput() + public void Dispose() { - // Arrange — source has First+Second+Shared. Skip-source has Shared. Output must contain - // First+Second only, exercising the skip-set integration with the streaming write path. - var source = CreateTempPath(); - DatabaseTestUtils.CreateV4Database(source, - DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName), - DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName), - DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); - - var skipSource = CreateTempPath(); - DatabaseTestUtils.CreateV4Database(skipSource, - DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); - - var path = CreateTempPath(); - var logger = Substitute.For(); - - // Act - new CreateDatabaseCommand(logger).CreateDatabase(path, source, filter: null, skipProvidersInFile: skipSource); - - // Assert - Assert.True(File.Exists(path)); + foreach (var path in _tempPaths) + { + DatabaseTestUtils.DeleteDatabaseFile(path); + } - using var verify = new ProviderDbContext(path, true); - var names = verify.ProviderDetails.Select(r => r.ProviderName).OrderBy(n => n).ToList(); - Assert.Equal(new[] { Constants.FirstProviderName, Constants.SecondProviderName }, names); - logger.DidNotReceive().Error(Arg.Any()); - logger.DidNotReceive().Warning(Arg.Any()); + foreach (var dir in _tempDirs) + { + DatabaseTestUtils.DeleteDirectoryRecursive(dir); + } } private string CreateTempPath() diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs index 7767d355..9e5f3518 100644 --- a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs +++ b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/MtaProviderSourceTests.cs @@ -12,19 +12,6 @@ public sealed class MtaProviderSourceTests : IDisposable private readonly List _tempDirs = []; private readonly List _tempFiles = []; - public void Dispose() - { - foreach (var dir in _tempDirs) - { - DatabaseTestUtils.DeleteDirectoryRecursive(dir); - } - - foreach (var file in _tempFiles) - { - DatabaseTestUtils.DeleteDatabaseFile(file); - } - } - [Fact] public void DiscoverProviderNames_WhenEvtxFileMissing_LogsErrorReturnsEmpty() { @@ -60,23 +47,51 @@ public void DiscoverProviderNames_WhenFilterRegexIsInvalid_LogsErrorReturnsEmpty h.ToString().Contains("Evtx file not found"))); } + public void Dispose() + { + foreach (var dir in _tempDirs) + { + DatabaseTestUtils.DeleteDirectoryRecursive(dir); + } + + foreach (var file in _tempFiles) + { + DatabaseTestUtils.DeleteDatabaseFile(file); + } + } + [Fact] - public void FindMtaFiles_WhenLocaleMetaDataDirectoryMissing_LogsErrorReturnsEmpty() + public void FindMtaFiles_WhenLocaleMetaDataContainsMtaFiles_ReturnsAllOrdinalSortedAndLogsCount() { - // Arrange — create a temp dir with an evtx file but NO LocaleMetaData subdir. + // Arrange var dir = DatabaseTestUtils.CreateTempDirectory(); _tempDirs.Add(dir); var evtxPath = Path.Combine(dir, "test.evtx"); File.WriteAllBytes(evtxPath, []); + var localeDir = Path.Combine(dir, "LocaleMetaData"); + Directory.CreateDirectory(localeDir); + + // Names chosen so non-ordinal sorts (e.g., culture-aware) would reorder them differently. + var b = Path.Combine(localeDir, "B-Provider.MTA"); + var a = Path.Combine(localeDir, "A-Provider.MTA"); + var c = Path.Combine(localeDir, "C-Provider.MTA"); + File.WriteAllBytes(a, [0x00]); + File.WriteAllBytes(b, [0x00]); + File.WriteAllBytes(c, [0x00]); + var logger = Substitute.For(); // Act var files = MtaProviderSource.FindMtaFiles(evtxPath, logger); - // Assert - Assert.Empty(files); - logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("No LocaleMetaData folder") && h.ToString().Contains(evtxPath))); + // Assert — ordinal order ensures consistent provider lookup precedence across locales. + Assert.Equal(3, files.Count); + Assert.Equal(a, files[0]); + Assert.Equal(b, files[1]); + Assert.Equal(c, files[2]); + logger.Received(1).Information(Arg.Is(h => + h.ToString().Contains("3 locale metadata file") && h.ToString().Contains(localeDir))); + logger.DidNotReceive().Error(Arg.Any()); } [Fact] @@ -101,36 +116,21 @@ public void FindMtaFiles_WhenLocaleMetaDataDirectoryIsEmpty_LogsErrorReturnsEmpt } [Fact] - public void FindMtaFiles_WhenLocaleMetaDataContainsMtaFiles_ReturnsAllOrdinalSortedAndLogsCount() + public void FindMtaFiles_WhenLocaleMetaDataDirectoryMissing_LogsErrorReturnsEmpty() { - // Arrange + // Arrange — create a temp dir with an evtx file but NO LocaleMetaData subdir. var dir = DatabaseTestUtils.CreateTempDirectory(); _tempDirs.Add(dir); var evtxPath = Path.Combine(dir, "test.evtx"); File.WriteAllBytes(evtxPath, []); - var localeDir = Path.Combine(dir, "LocaleMetaData"); - Directory.CreateDirectory(localeDir); - - // Names chosen so non-ordinal sorts (e.g., culture-aware) would reorder them differently. - var b = Path.Combine(localeDir, "B-Provider.MTA"); - var a = Path.Combine(localeDir, "A-Provider.MTA"); - var c = Path.Combine(localeDir, "C-Provider.MTA"); - File.WriteAllBytes(a, [0x00]); - File.WriteAllBytes(b, [0x00]); - File.WriteAllBytes(c, [0x00]); - var logger = Substitute.For(); // Act var files = MtaProviderSource.FindMtaFiles(evtxPath, logger); - // Assert — ordinal order ensures consistent provider lookup precedence across locales. - Assert.Equal(3, files.Count); - Assert.Equal(a, files[0]); - Assert.Equal(b, files[1]); - Assert.Equal(c, files[2]); - logger.Received(1).Information(Arg.Is(h => - h.ToString().Contains("3 locale metadata file") && h.ToString().Contains(localeDir))); - logger.DidNotReceive().Error(Arg.Any()); + // Assert + Assert.Empty(files); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("No LocaleMetaData folder") && h.ToString().Contains(evtxPath))); } } diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs index 1f0adb85..22b73905 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/ProviderDatabase/ProviderDbContextTests.cs @@ -415,60 +415,60 @@ public void PerformUpgradeIfNeeded_WithV1Schema_ThrowsAndPreservesLegacyTable() } [Fact] - public void PerformUpgradeIfNeeded_WithV2SchemaAndNullParameters_Throws() + public void PerformUpgradeIfNeeded_WithV2Schema_Throws() { - // Arrange — V2 row with NULL Parameters column; previously this would have silently - // round-tripped to an empty list. Now it must surface the same hard-fail as any other V2 row. + // Arrange — V2 row stores Parameters as JSON TEXT. var dbPath = CreateTempDatabasePath(); SeedLegacySchema(dbPath, includeParameters: true, parametersType: "TEXT", messagesType: "TEXT"); InsertLegacyRow( dbPath, - providerName: "V2NullParams", + providerName: "V2Provider", messagesJson: "[]", - parametersJson: null, + parametersJson: "[{\"ShortId\":2,\"LogLink\":null,\"RawId\":2,\"Tag\":null,\"Template\":null,\"Text\":\"param-text\"}]", eventsJson: "[]", keywordsJson: "{}", opcodesJson: "{}", tasksJson: "{}"); // Act + Assert - using var context = new ProviderDbContext(dbPath, false); - var thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); + DatabaseUpgradeException? thrown; + using (var context = new ProviderDbContext(dbPath, false)) + { + thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); + } + Assert.Contains("v2", thrown.Reason); + Assert.Contains("no longer supported", thrown.Reason); + // Original V2 row preserved; detection still reports v2. + using var verify = new ProviderDbContext(dbPath, true); + var stateAfter = verify.IsUpgradeNeeded(); + Assert.Equal(2, stateAfter.CurrentVersion); AssertProviderDetailsRowCount(dbPath, expectedRows: 1); } [Fact] - public void PerformUpgradeIfNeeded_WithV2Schema_Throws() + public void PerformUpgradeIfNeeded_WithV2SchemaAndNullParameters_Throws() { - // Arrange — V2 row stores Parameters as JSON TEXT. + // Arrange — V2 row with NULL Parameters column; previously this would have silently + // round-tripped to an empty list. Now it must surface the same hard-fail as any other V2 row. var dbPath = CreateTempDatabasePath(); SeedLegacySchema(dbPath, includeParameters: true, parametersType: "TEXT", messagesType: "TEXT"); InsertLegacyRow( dbPath, - providerName: "V2Provider", + providerName: "V2NullParams", messagesJson: "[]", - parametersJson: "[{\"ShortId\":2,\"LogLink\":null,\"RawId\":2,\"Tag\":null,\"Template\":null,\"Text\":\"param-text\"}]", + parametersJson: null, eventsJson: "[]", keywordsJson: "{}", opcodesJson: "{}", tasksJson: "{}"); // Act + Assert - DatabaseUpgradeException? thrown; - using (var context = new ProviderDbContext(dbPath, false)) - { - thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); - } - + using var context = new ProviderDbContext(dbPath, false); + var thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); Assert.Contains("v2", thrown.Reason); - Assert.Contains("no longer supported", thrown.Reason); - // Original V2 row preserved; detection still reports v2. - using var verify = new ProviderDbContext(dbPath, true); - var stateAfter = verify.IsUpgradeNeeded(); - Assert.Equal(2, stateAfter.CurrentVersion); AssertProviderDetailsRowCount(dbPath, expectedRows: 1); } diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs index 3c5c89ee..c8a70a3b 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/EventMessageProviderIntegrationTests.cs @@ -1,11 +1,11 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Interop; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Globalization; using System.Runtime.InteropServices; @@ -68,22 +68,6 @@ public void LoadProviderDetails_ShouldLogProviderLoadingAttempt() mockLogger.Received().Debug(Arg.Any()); } - [Fact] - public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() - { - // Arrange - EventMessageProvider provider = new(Constants.TestProviderName); - - // Act - var details1 = provider.LoadProviderDetails(); - var details2 = provider.LoadProviderDetails(); - - // Assert - Assert.NotNull(details1); - Assert.NotNull(details2); - Assert.Equal(details1.ProviderName, details2.ProviderName); - } - [Fact] public void LoadProviderDetails_WhenCalled_ShouldHaveNonNullCollections() { @@ -117,6 +101,22 @@ public void LoadProviderDetails_WhenCalled_ShouldReturnProviderDetails() Assert.Equal(Constants.TestProviderName, details.ProviderName); } + [Fact] + public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() + { + // Arrange + EventMessageProvider provider = new(Constants.TestProviderName); + + // Act + var details1 = provider.LoadProviderDetails(); + var details2 = provider.LoadProviderDetails(); + + // Assert + Assert.NotNull(details1); + Assert.NotNull(details2); + Assert.Equal(details1.ProviderName, details2.ProviderName); + } + [Fact] public void LoadProviderDetails_WhenChannelOwningPublisherUnknown_ShouldReturnEmptyDetailsWithoutFallback() { @@ -139,9 +139,10 @@ public void LoadProviderDetails_WhenLegacyLoadReturnsNoMessages_ShouldFallBackTo Assert.SkipUnless( TryFindProviderWithEmptyLegacyAndWorkingModern(out var providerName), "Test requires a provider whose legacy registry entries load zero messages and whose modern publisher metadata exposes a loadable MessageFilePath. Common on dev machines but not guaranteed."); + Assert.NotNull(providerName); var mockLogger = Substitute.For(); - EventMessageProvider provider = new(providerName!, logger: mockLogger); + EventMessageProvider provider = new(providerName, logger: mockLogger); var details = provider.LoadProviderDetails(); @@ -175,8 +176,9 @@ public void LoadProviderDetails_WhenProviderNameIsActuallyAChannelPath_ShouldFal Assert.SkipUnless( TryFindChannelWithDistinctOwningPublisher(out var channelName, out var owningPublisher), "Test requires a registered channel whose owning publisher differs from the channel path itself."); + Assert.NotNull(channelName); - EventMessageProvider provider = new(channelName!); + EventMessageProvider provider = new(channelName); var details = provider.LoadProviderDetails(); diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs index c5d17e1d..476daed1 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/ProviderMetadataTests.cs @@ -1,9 +1,9 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; +using EventLogExpert.Eventing.TestUtils.Constants; using NSubstitute; using System.Collections.ObjectModel; @@ -29,8 +29,11 @@ public async Task Channels_WhenAccessedConcurrently_ShouldReturnValidData() // Assert var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, Assert.NotNull); - Assert.All(results, r => Assert.NotEmpty(r!)); + Assert.All(results, r => + { + Assert.NotNull(r); + Assert.NotEmpty(r); + }); } [Fact] @@ -434,8 +437,9 @@ public void MessageFilePath_WhenManifestUsesEnvironmentVariables_ShouldReturnExp var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); Assert.SkipUnless(metadata is not null, "Test requires Microsoft-Windows-Security-Auditing provider on the host."); + Assert.NotNull(metadata); - var messageFilePath = metadata!.MessageFilePath; + var messageFilePath = metadata.MessageFilePath; Assert.NotNull(messageFilePath); Assert.DoesNotContain("%", messageFilePath); @@ -501,8 +505,11 @@ public async Task Opcodes_WhenAccessedConcurrently_ShouldReturnValidData() // Assert var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, Assert.NotNull); - Assert.All(results, r => Assert.NotEmpty(r!)); + Assert.All(results, r => + { + Assert.NotNull(r); + Assert.NotEmpty(r); + }); } [Fact] @@ -608,8 +615,9 @@ public void ParameterFilePath_WhenManifestUsesEnvironmentVariables_ShouldReturnE var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); Assert.SkipUnless(metadata is not null, "Test requires Microsoft-Windows-Security-Auditing provider on the host."); + Assert.NotNull(metadata); - var parameterFilePath = metadata!.ParameterFilePath; + var parameterFilePath = metadata.ParameterFilePath; Assert.NotNull(parameterFilePath); Assert.DoesNotContain("%", parameterFilePath); @@ -647,8 +655,11 @@ public async Task Tasks_WhenAccessedConcurrently_ShouldReturnValidData() // Assert var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, Assert.NotNull); - Assert.All(results, r => Assert.NotEmpty(r!)); + Assert.All(results, r => + { + Assert.NotNull(r); + Assert.NotEmpty(r); + }); } [Fact] diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs index 379a7df0..30aad112 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Providers/RegistryProviderTests.cs @@ -2,9 +2,9 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Logging; using EventLogExpert.Eventing.Providers; +using EventLogExpert.Eventing.TestUtils.Constants; using Microsoft.Win32; using NSubstitute; @@ -43,6 +43,26 @@ public void Constructor_WhenCreatedMultipleTimes_ShouldCreateDifferentInstances( Assert.NotSame(provider1, provider2); } + [Fact] + public void GetMessageFilesForLegacyProvider_WhenCalled_ShouldNotIncludeSysFiles() + { + // Arrange + var provider = new RegistryProvider(); + + // Act + var result = FindAnyLegacyProviderFiles(provider); + + // Assert + Assert.NotEmpty(result); + + Assert.All(result, + path => + { + var extension = Path.GetExtension(path).ToLower(); + Assert.NotEqual(".sys", extension); + }); + } + [Fact] public void GetMessageFilesForLegacyProvider_WhenCalledWithLogger_ShouldLogTrace() { @@ -74,26 +94,6 @@ public void GetMessageFilesForLegacyProvider_WhenCalledWithoutLogger_ShouldNotTh Assert.NotNull(result); } - [Fact] - public void GetMessageFilesForLegacyProvider_WhenCalled_ShouldNotIncludeSysFiles() - { - // Arrange - var provider = new RegistryProvider(); - - // Act - var result = FindAnyLegacyProviderFiles(provider); - - // Assert - Assert.NotEmpty(result); - - Assert.All(result, - path => - { - var extension = Path.GetExtension(path).ToLower(); - Assert.NotEqual(".sys", extension); - }); - } - [Theory] [InlineData(Constants.ApplicationLogName)] [InlineData(Constants.SystemLogName)] diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs index aca1d610..e6d51b81 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogInformationTests.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; namespace EventLogExpert.Eventing.IntegrationTests.Readers; diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs index e5b5b169..c6509292 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderTests.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; namespace EventLogExpert.Eventing.IntegrationTests.Readers; @@ -244,34 +244,34 @@ public void TryGetEvents_WhenApplicationLog_ShouldReturnValidEventRecords() } [Fact] - public void TryGetEvents_WhenBatchSize10_ShouldReturnUpTo10Events() + public void TryGetEvents_WhenBatchSize1_ShouldReturn1Event() { // Arrange using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); // Act - bool success = reader.TryGetEvents(out var events, batchSize: 10); + bool success = reader.TryGetEvents(out var events, batchSize: 1); // Assert - Assert.True(success); - Assert.NotNull(events); - Assert.True(events.Length <= 10); + if (success && events.Length > 0) + { + Assert.Single(events); + } } [Fact] - public void TryGetEvents_WhenBatchSize1_ShouldReturn1Event() + public void TryGetEvents_WhenBatchSize10_ShouldReturnUpTo10Events() { // Arrange using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel); // Act - bool success = reader.TryGetEvents(out var events, batchSize: 1); + bool success = reader.TryGetEvents(out var events, batchSize: 10); // Assert - if (success && events.Length > 0) - { - Assert.Single(events); - } + Assert.True(success); + Assert.NotNull(events); + Assert.True(events.Length <= 10); } [Fact] diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs index 2ad22f4b..5359c415 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogSessionTests.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; namespace EventLogExpert.Eventing.IntegrationTests.Readers; @@ -92,26 +92,6 @@ public void GetLogNames_AfterGetProviderNames_ShouldStillWork() Assert.NotEmpty(logNames); } - [Fact] - public void GetLogNames_WhenCalledMultipleTimes_ShouldReturnConsistentResults() - { - // Arrange - var session = EventLogSession.GlobalSession; - - // Act - var logNames1 = session.GetLogNames().ToList(); - var logNames2 = session.GetLogNames().ToList(); - - // Assert - Assert.Equal(logNames1.Count, logNames2.Count); - - // All names from first call should be in second call - foreach (var name in logNames1) - { - Assert.Contains(name, logNames2); - } - } - [Fact] public void GetLogNames_WhenCalled_AllNamesShouldBeNonEmpty() { @@ -215,6 +195,26 @@ public void GetLogNames_WhenCalled_ShouldReturnOrderedList() Assert.Equal(sortedNames, logNames); } + [Fact] + public void GetLogNames_WhenCalledMultipleTimes_ShouldReturnConsistentResults() + { + // Arrange + var session = EventLogSession.GlobalSession; + + // Act + var logNames1 = session.GetLogNames().ToList(); + var logNames2 = session.GetLogNames().ToList(); + + // Assert + Assert.Equal(logNames1.Count, logNames2.Count); + + // All names from first call should be in second call + foreach (var name in logNames1) + { + Assert.Contains(name, logNames2); + } + } + [Fact] public async Task GetLogNames_WhenConcurrentAccess_ShouldHandleMultipleThreads() { @@ -254,26 +254,6 @@ public void GetProviderNames_AfterGetLogNames_ShouldStillWork() Assert.NotEmpty(providers); } - [Fact] - public void GetProviderNames_WhenCalledMultipleTimes_ShouldReturnConsistentResults() - { - // Arrange - var session = EventLogSession.GlobalSession; - - // Act - var providers1 = session.GetProviderNames(); - var providers2 = session.GetProviderNames(); - - // Assert - Assert.Equal(providers1.Count, providers2.Count); - - // All providers from first call should be in second call - foreach (var provider in providers1) - { - Assert.Contains(provider, providers2); - } - } - [Fact] public void GetProviderNames_WhenCalled_AllNamesShouldBeNonEmpty() { @@ -349,6 +329,26 @@ public void GetProviderNames_WhenCalled_ShouldReturnProviderNames() Assert.NotEmpty(providers); } + [Fact] + public void GetProviderNames_WhenCalledMultipleTimes_ShouldReturnConsistentResults() + { + // Arrange + var session = EventLogSession.GlobalSession; + + // Act + var providers1 = session.GetProviderNames(); + var providers2 = session.GetProviderNames(); + + // Assert + Assert.Equal(providers1.Count, providers2.Count); + + // All providers from first call should be in second call + foreach (var provider in providers1) + { + Assert.Contains(provider, providers2); + } + } + [Fact] public async Task GetProviderNames_WhenConcurrentAccess_ShouldHandleMultipleThreads() { @@ -374,26 +374,26 @@ public async Task GetProviderNames_WhenConcurrentAccess_ShouldHandleMultipleThre } [Fact] - public void GlobalSession_WhenAccessedMultipleTimes_ShouldReturnSameInstance() + public void GlobalSession_WhenAccessed_ShouldNotBeNull() { // Arrange & Act - var session1 = EventLogSession.GlobalSession; - var session2 = EventLogSession.GlobalSession; + var session = EventLogSession.GlobalSession; // Assert - Assert.NotNull(session1); - Assert.NotNull(session2); - Assert.Same(session1, session2); + Assert.NotNull(session); } [Fact] - public void GlobalSession_WhenAccessed_ShouldNotBeNull() + public void GlobalSession_WhenAccessedMultipleTimes_ShouldReturnSameInstance() { // Arrange & Act - var session = EventLogSession.GlobalSession; + var session1 = EventLogSession.GlobalSession; + var session2 = EventLogSession.GlobalSession; // Assert - Assert.NotNull(session); + Assert.NotNull(session1); + Assert.NotNull(session2); + Assert.Same(session1, session2); } [Fact] diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs index 92821ddf..4508b0c5 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogWatcherTests.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.TestUtils.Constants; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; using System.Collections.Concurrent; using System.Diagnostics; using System.Reflection; @@ -142,6 +142,20 @@ public void Dispose_ShouldReleaseUnderlyingWaitHandle() Assert.True(newEvents.SafeWaitHandle.IsClosed); } + [Fact] + public void Dispose_WhenCalled_ShouldUnsubscribe() + { + // Arrange + var watcher = new EventLogWatcher(Constants.ApplicationLogName); + watcher.Enabled = true; + + // Act + watcher.Dispose(); + + // Assert + Assert.False(watcher.Enabled); + } + [Fact] public void Dispose_WhenCalledFromHandler_ShouldThrowInvalidOperationException() { @@ -154,7 +168,8 @@ public void Dispose_WhenCalledFromHandler_ShouldThrowInvalidOperationException() { try { - ((EventLogWatcher)sender!).Dispose(); + Assert.NotNull(sender); + ((EventLogWatcher)sender).Dispose(); } catch (Exception ex) { @@ -196,20 +211,6 @@ public void Dispose_WhenCalledMultipleTimes_ShouldNotThrow() watcher.Dispose(); } - [Fact] - public void Dispose_WhenCalled_ShouldUnsubscribe() - { - // Arrange - var watcher = new EventLogWatcher(Constants.ApplicationLogName); - watcher.Enabled = true; - - // Act - watcher.Dispose(); - - // Assert - Assert.False(watcher.Enabled); - } - [Fact] public void Dispose_WhenNotSubscribed_ShouldNotThrow() { @@ -288,6 +289,20 @@ public async Task Enabled_WhenRacingFromMultipleThreads_ShouldNotHangOrCorruptSt await allDone.WaitAsync(TimeSpan.FromSeconds(30), ct); } + [Fact] + public void Enabled_WhenSetToFalse_ShouldUnsubscribe() + { + // Arrange + using var watcher = new EventLogWatcher(Constants.ApplicationLogName); + watcher.Enabled = true; + + // Act + watcher.Enabled = false; + + // Assert + Assert.False(watcher.Enabled); + } + [Fact] public void Enabled_WhenSetToFalseFromHandler_ShouldThrowInvalidOperationException() { @@ -300,7 +315,8 @@ public void Enabled_WhenSetToFalseFromHandler_ShouldThrowInvalidOperationExcepti { try { - ((EventLogWatcher)sender!).Enabled = false; + Assert.NotNull(sender); + ((EventLogWatcher)sender).Enabled = false; } catch (Exception ex) { @@ -343,29 +359,28 @@ public void Enabled_WhenSetToFalseTwice_ShouldNotThrow() } [Fact] - public void Enabled_WhenSetToFalse_ShouldUnsubscribe() + public void Enabled_WhenSetToSameValue_ShouldNotThrow() { // Arrange using var watcher = new EventLogWatcher(Constants.ApplicationLogName); - watcher.Enabled = true; - - // Act watcher.Enabled = false; - // Assert + // Act & Assert + watcher.Enabled = false; Assert.False(watcher.Enabled); } [Fact] - public void Enabled_WhenSetToSameValue_ShouldNotThrow() + public void Enabled_WhenSetToTrue_ShouldSubscribe() { // Arrange using var watcher = new EventLogWatcher(Constants.ApplicationLogName); - watcher.Enabled = false; - // Act & Assert - watcher.Enabled = false; - Assert.False(watcher.Enabled); + // Act + watcher.Enabled = true; + + // Assert + Assert.True(watcher.Enabled); } [Fact] @@ -407,19 +422,6 @@ public void Enabled_WhenSetToTrueTwice_ShouldRemainEnabled() Assert.True(watcher.Enabled); } - [Fact] - public void Enabled_WhenSetToTrue_ShouldSubscribe() - { - // Arrange - using var watcher = new EventLogWatcher(Constants.ApplicationLogName); - - // Act - watcher.Enabled = true; - - // Assert - Assert.True(watcher.Enabled); - } - [Fact] public void Enabled_WhenToggled_ShouldUpdateState() { diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs index 6ca7ecf3..f5b20213 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/EventProviderDatabaseEventResolverTests.cs @@ -228,40 +228,40 @@ public void Dispose_ThenLoadProviderDetails_ShouldThrowObjectDisposedException() } [Fact] - public void Dispose_ThenResolveEventViaBaseReference_ShouldThrowObjectDisposedException() + public void Dispose_ThenResolveEvent_ShouldThrowObjectDisposedException() { // Arrange var dbCollection = Substitute.For(); dbCollection.ActiveDatabases.Returns([]); - EventResolver resolver = new EventResolver(dbCollection); - - // Type as base class to verify override (not 'new') is used - EventResolverBase baseResolver = resolver; + var resolver = new EventResolver(dbCollection); var eventRecord = EventUtils.CreateBasicEvent(); // Act resolver.Dispose(); - // Assert - This should throw because ResolveEvent is overridden, not hidden with 'new' - Assert.Throws(() => baseResolver.ResolveEvent(eventRecord)); + // Assert + Assert.Throws(() => resolver.ResolveEvent(eventRecord)); } [Fact] - public void Dispose_ThenResolveEvent_ShouldThrowObjectDisposedException() + public void Dispose_ThenResolveEventViaBaseReference_ShouldThrowObjectDisposedException() { // Arrange var dbCollection = Substitute.For(); dbCollection.ActiveDatabases.Returns([]); - var resolver = new EventResolver(dbCollection); + EventResolver resolver = new(dbCollection); + + // Type as base class to verify override (not 'new') is used + EventResolverBase baseResolver = resolver; var eventRecord = EventUtils.CreateBasicEvent(); // Act resolver.Dispose(); - // Assert - Assert.Throws(() => resolver.ResolveEvent(eventRecord)); + // Assert - This should throw because ResolveEvent is overridden, not hidden with 'new' + Assert.Throws(() => baseResolver.ResolveEvent(eventRecord)); } [Fact] diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs index ed3787ad..2953c0e7 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Resolvers/LocalProviderEventResolverTests.cs @@ -155,19 +155,32 @@ public void LoadProviderDetails_CalledTwiceForSameProvider_ShouldNotThrow() } [Fact] - public void LoadProviderDetails_ConcurrentCallsForSameProvider_ShouldHandleThreadSafely() + public void LoadProviderDetails_ConcurrentCalls_ShouldHandleThreadSafely() { // Arrange var resolver = new EventResolver(); - var exceptions = new Exception?[50]; + + var providerNames = new[] + { + Constants.ApplicationLogName, + Constants.SystemLogName, + Constants.TestProviderName, + Constants.TestProviderLongName + }; + + var exceptions = new Exception?[20]; // Act - Parallel.For(0, 50, i => + Parallel.For(0, 20, i => { try { - var eventRecord = EventUtils.CreateBasicEvent(); - eventRecord.Id = (ushort)(1000 + i); + var eventRecord = new EventRecord + { + ProviderName = providerNames[i % providerNames.Length], + Id = (ushort)(1000 + i) + }; + resolver.LoadProviderDetails(eventRecord); } catch (Exception ex) @@ -181,32 +194,19 @@ public void LoadProviderDetails_ConcurrentCallsForSameProvider_ShouldHandleThrea } [Fact] - public void LoadProviderDetails_ConcurrentCalls_ShouldHandleThreadSafely() + public void LoadProviderDetails_ConcurrentCallsForSameProvider_ShouldHandleThreadSafely() { // Arrange var resolver = new EventResolver(); - - var providerNames = new[] - { - Constants.ApplicationLogName, - Constants.SystemLogName, - Constants.TestProviderName, - Constants.TestProviderLongName - }; - - var exceptions = new Exception?[20]; + var exceptions = new Exception?[50]; // Act - Parallel.For(0, 20, i => + Parallel.For(0, 50, i => { try { - var eventRecord = new EventRecord - { - ProviderName = providerNames[i % providerNames.Length], - Id = (ushort)(1000 + i) - }; - + var eventRecord = EventUtils.CreateBasicEvent(); + eventRecord.Id = (ushort)(1000 + i); resolver.LoadProviderDetails(eventRecord); } catch (Exception ex) diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs index f29b0844..9a355afa 100644 --- a/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/Database/DatabaseServiceTests.cs @@ -258,51 +258,48 @@ public async Task ClassifyEntriesAsync_WhenV3BakAppearsBetweenClassifications_Sh } [Fact] - public async Task ClassifyEntriesAsync_WhenV3SchemaWithUpgradeBak_ShouldDetectAsUpgradeRequiredAndBackupExistsTrue() + public async Task ClassifyEntriesAsync_WhenV3Schema_ShouldDetectAsUpgradeRequired() { var databasePath = CreateDatabaseDirectory(); var dbPath = Path.Combine(databasePath, Constants.TestDb1); DatabaseSeedUtils.SeedV3Schema(dbPath); - var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; - File.WriteAllText(bakPath, "interrupted-upgrade-backup"); - var service = CreateDatabaseService(); await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); - Assert.True(entry.BackupExists); - Assert.True(File.Exists(bakPath), ".upgrade.bak must be preserved for V3 entries so recovery can restore it."); + Assert.False(entry.BackupExists); } [Fact] - public async Task ClassifyEntriesAsync_WhenV3Schema_ShouldDetectAsUpgradeRequired() + public async Task ClassifyEntriesAsync_WhenV3SchemaWithUpgradeBak_ShouldDetectAsUpgradeRequiredAndBackupExistsTrue() { var databasePath = CreateDatabaseDirectory(); var dbPath = Path.Combine(databasePath, Constants.TestDb1); DatabaseSeedUtils.SeedV3Schema(dbPath); + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "interrupted-upgrade-backup"); + var service = CreateDatabaseService(); await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); - Assert.False(entry.BackupExists); + Assert.True(entry.BackupExists); + Assert.True(File.Exists(bakPath), ".upgrade.bak must be preserved for V3 entries so recovery can restore it."); } [Fact] - public async Task ClassifyEntriesAsync_WhenV4SchemaWithUpgradeBak_ShouldDeleteBakAndDetectAsReady() + public async Task ClassifyEntriesAsync_WhenV4Schema_ShouldDetectAsReady() { var databasePath = CreateDatabaseDirectory(); var dbPath = Path.Combine(databasePath, Constants.TestDb1); DatabaseSeedUtils.SeedV4Schema(dbPath); - var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; - File.WriteAllText(bakPath, "stale-backup-from-successful-upgrade"); - var service = CreateDatabaseService(); await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); @@ -310,16 +307,18 @@ public async Task ClassifyEntriesAsync_WhenV4SchemaWithUpgradeBak_ShouldDeleteBa var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.Ready, entry.Status); Assert.False(entry.BackupExists); - Assert.False(File.Exists(bakPath), "Stale .upgrade.bak must be cleaned up once the main file reaches V4."); } [Fact] - public async Task ClassifyEntriesAsync_WhenV4Schema_ShouldDetectAsReady() + public async Task ClassifyEntriesAsync_WhenV4SchemaWithUpgradeBak_ShouldDeleteBakAndDetectAsReady() { var databasePath = CreateDatabaseDirectory(); var dbPath = Path.Combine(databasePath, Constants.TestDb1); DatabaseSeedUtils.SeedV4Schema(dbPath); + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "stale-backup-from-successful-upgrade"); + var service = CreateDatabaseService(); await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); @@ -327,6 +326,7 @@ public async Task ClassifyEntriesAsync_WhenV4Schema_ShouldDetectAsReady() var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.Ready, entry.Status); Assert.False(entry.BackupExists); + Assert.False(File.Exists(bakPath), "Stale .upgrade.bak must be cleaned up once the main file reaches V4."); } [Fact] @@ -647,25 +647,6 @@ public async Task DisposeAsync_WithPendingBatches_ShouldCancelInFlightAndPending Assert.Equal(Constants.TestDb2, pendingResult.Cancelled[0]); } - [Fact] - public void EntriesChanged_MultipleSubscribers_FirstThrows_ShouldStillInvokeRest() - { - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - - var service = CreateDatabaseService(); - - var secondSubscriberInvocations = 0; - - service.EntriesChanged += (_, _) => throw new InvalidOperationException("first subscriber throws"); - service.EntriesChanged += (_, _) => Interlocked.Increment(ref secondSubscriberInvocations); - - service.Toggle(Constants.TestDb1); - - // If multicast invoke aborted on the first throwing subscriber, this would be 0. - Assert.Equal(1, secondSubscriberInvocations); - } - [Fact] public void Entries_WhenMixedVersionedAndNonVersioned_ShouldSortCorrectly() { @@ -726,6 +707,25 @@ public void Entries_WhenSimpleNames_ShouldSortByNameAscThenVersionDesc() Assert.Equal(Constants.DatabaseA + ".db", service.Entries[2].FileName); } + [Fact] + public void EntriesChanged_MultipleSubscribers_FirstThrows_ShouldStillInvokeRest() + { + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + + var secondSubscriberInvocations = 0; + + service.EntriesChanged += (_, _) => throw new InvalidOperationException("first subscriber throws"); + service.EntriesChanged += (_, _) => Interlocked.Increment(ref secondSubscriberInvocations); + + service.Toggle(Constants.TestDb1); + + // If multicast invoke aborted on the first throwing subscriber, this would be 0. + Assert.Equal(1, secondSubscriberInvocations); + } + [Fact] public async Task EnumerateZipDbEntryNamesAsync_MalformedZip_ShouldReturnEmpty_NotThrow() { @@ -2144,7 +2144,7 @@ public async Task UpgradeBatchAsync_DuringMigration_ShouldNotSetBackupExistsOnEn Assert.Single(result.Succeeded); Assert.NotNull(backupExistsDuringMigration); - Assert.False(backupExistsDuringMigration!.Value); + Assert.False(backupExistsDuringMigration.Value); } [Fact] diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs b/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs index 1de0eb4a..0123601c 100644 --- a/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs +++ b/tests/Integration/EventLogExpert.UI.IntegrationTests/DebugLog/DebugLogServiceTests.cs @@ -3,8 +3,8 @@ using EventLogExpert.UI.Common.Files; using EventLogExpert.UI.DebugLog; -using EventLogExpert.UI.Settings; using EventLogExpert.UI.IntegrationTests.TestUtils.Constants; +using EventLogExpert.UI.Settings; using Microsoft.Extensions.Logging; using NSubstitute; @@ -330,37 +330,6 @@ public void MinimumLevel_WhenLogLevelChangedAtRuntime_ShouldReflectNewLevel() Assert.Equal(LogLevel.Warning, debugLogService.MinimumLevel); } - [Fact] - public void TraceIfEnabled_WhenLogLevelChangedAtRuntime_ShouldRespectNewLevel() - { - // Arrange - var (mockSettingsService, setLogLevel) = CreateMockSettingsServiceWithDynamicLogLevel(LogLevel.Debug); - - using var debugLogService = new DebugLogService( - new FileLocationOptions(_testDirectory), - mockSettingsService); - - // Act - write at Debug (should succeed) - debugLogService.Debug($"debug message before change"); - var contentBefore = ReadLogFile(); - Assert.Contains("debug message before change", contentBefore); - - // Change level to Warning - setLogLevel(LogLevel.Warning); - - // Write at Debug (should be filtered by handler) - debugLogService.Debug($"debug message after change"); - - // Write at Warning (should succeed) - debugLogService.Warning($"warning message after change"); - - // Assert - var content = ReadLogFile(); - Assert.Contains("debug message before change", content); - Assert.DoesNotContain("debug message after change", content); - Assert.Contains("warning message after change", content); - } - [Fact] public void Trace_ShouldIncludeThreadId() { @@ -448,7 +417,8 @@ public async Task Trace_WhenCalledFromMultipleThreadsConcurrently_ShouldProduceO DebugLogEntryParser.TryParseLine(line, out var entry), $"Line did not parse via DebugLogEntryParser: {line}"); - timestamps.Add(entry.Timestamp!.Value); + Assert.NotNull(entry.Timestamp); + timestamps.Add(entry.Timestamp.Value); payloads.Add(entry.Message); } @@ -614,6 +584,37 @@ public void Trace_WithDefaultLogLevel_ShouldUseInformation() Assert.Contains(Constants.DebugLogDefaultLevelMessage, content); } + [Fact] + public void TraceIfEnabled_WhenLogLevelChangedAtRuntime_ShouldRespectNewLevel() + { + // Arrange + var (mockSettingsService, setLogLevel) = CreateMockSettingsServiceWithDynamicLogLevel(LogLevel.Debug); + + using var debugLogService = new DebugLogService( + new FileLocationOptions(_testDirectory), + mockSettingsService); + + // Act - write at Debug (should succeed) + debugLogService.Debug($"debug message before change"); + var contentBefore = ReadLogFile(); + Assert.Contains("debug message before change", contentBefore); + + // Change level to Warning + setLogLevel(LogLevel.Warning); + + // Write at Debug (should be filtered by handler) + debugLogService.Debug($"debug message after change"); + + // Write at Warning (should succeed) + debugLogService.Warning($"warning message after change"); + + // Assert + var content = ReadLogFile(); + Assert.Contains("debug message before change", content); + Assert.DoesNotContain("debug message after change", content); + Assert.Contains("warning message after change", content); + } + [Fact] public void WriteTrace_WhenSecondInstanceWritesToSameFile_ShouldNotThrowFileLockException() { diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs index 8cd1fc30..a0696aa9 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/SqliteTestDb.cs @@ -5,28 +5,21 @@ namespace EventLogExpert.Eventing.TestUtils; -/// -/// Helpers for managing SQLite test database files. -/// +/// Helpers for managing SQLite test database files. public static class SqliteTestDb { - /// - /// Deletes a SQLite test database file with retries, after first releasing pooled - /// SqliteConnection handles. - /// + /// Deletes a SQLite test database file with retries, after first releasing pooled SqliteConnection handles. /// - /// - /// uses - /// EF Core's default pooled SqliteConnection. Without - /// the pooled handle still owns the file - /// after the context is disposed and hits a sharing violation. - /// - /// - /// mutates process-wide state, so it must - /// only be called from a test assembly configured for serial execution - /// (xunit.runner.json with parallelizeTestCollections: false) — otherwise it can - /// race with concurrent SQLite work on another thread. - /// + /// + /// uses EF Core's default pooled + /// SqliteConnection. Without the pooled handle still owns the file + /// after the context is disposed and hits a sharing violation. + /// + /// + /// mutates process-wide state, so it must only be called from a + /// test assembly configured for serial execution (xunit.runner.json with parallelizeTestCollections: false) + /// — otherwise it can race with concurrent SQLite work on another thread. + /// /// public static void Delete(string? path, int maxAttempts = 10, int delayMs = 200) { @@ -62,13 +55,12 @@ public static void Delete(string? path, int maxAttempts = 10, int delayMs = 200) } /// - /// Recursively deletes a directory containing SQLite test database files, with retries, - /// after first releasing pooled SqliteConnection handles. + /// Recursively deletes a directory containing SQLite test database files, with retries, after first releasing + /// pooled SqliteConnection handles. /// /// - /// Same caveats as : mutates - /// process-wide state, so this helper must only be called from a test assembly configured - /// for serial execution. + /// Same caveats as : mutates process-wide + /// state, so this helper must only be called from a test assembly configured for serial execution. /// public static void DeleteDirectory(string? path, int maxAttempts = 10, int delayMs = 200) { diff --git a/tests/Unit/EventLogExpert.Components.Tests/BannerHostTests.cs b/tests/Unit/EventLogExpert.Components.Tests/BannerHostTests.cs index b5826b23..fcd087db 100644 --- a/tests/Unit/EventLogExpert.Components.Tests/BannerHostTests.cs +++ b/tests/Unit/EventLogExpert.Components.Tests/BannerHostTests.cs @@ -66,29 +66,29 @@ public void BannerHost_AttentionDismissed_DoesNotRenderAttentionBanner() } [Fact] - public void BannerHost_AttentionEntriesSingleEntry_UsesSingularDatabaseLabel() + public void BannerHost_AttentionEntries_RendersAttentionBannerWithOpenSettingsAndDismiss() { - _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); + _bannerService.AttentionEntries.Returns( + [BuildDatabaseEntry("a.db"), BuildDatabaseEntry("b.db")]); var component = Render(); var banner = component.Find("aside.banner-attention"); - Assert.Contains("1 database need", banner.TextContent); - Assert.DoesNotContain("databases need", banner.TextContent); + Assert.Contains("2 databases need attention", banner.TextContent); + Assert.Equal("Open Settings", component.Find("aside.banner-attention button.banner-action").TextContent.Trim()); + Assert.Single(component.FindAll("aside.banner-attention button.banner-dismiss")); } [Fact] - public void BannerHost_AttentionEntries_RendersAttentionBannerWithOpenSettingsAndDismiss() + public void BannerHost_AttentionEntriesSingleEntry_UsesSingularDatabaseLabel() { - _bannerService.AttentionEntries.Returns( - [BuildDatabaseEntry("a.db"), BuildDatabaseEntry("b.db")]); + _bannerService.AttentionEntries.Returns([BuildDatabaseEntry("a.db")]); var component = Render(); var banner = component.Find("aside.banner-attention"); - Assert.Contains("2 databases need attention", banner.TextContent); - Assert.Equal("Open Settings", component.Find("aside.banner-attention button.banner-action").TextContent.Trim()); - Assert.Single(component.FindAll("aside.banner-attention button.banner-dismiss")); + Assert.Contains("1 database need", banner.TextContent); + Assert.DoesNotContain("databases need", banner.TextContent); } [Fact] diff --git a/tests/Unit/EventLogExpert.Components.Tests/Database/DatabaseEntryRowTests.cs b/tests/Unit/EventLogExpert.Components.Tests/Database/DatabaseEntryRowTests.cs index a921a645..b8cb97ee 100644 --- a/tests/Unit/EventLogExpert.Components.Tests/Database/DatabaseEntryRowTests.cs +++ b/tests/Unit/EventLogExpert.Components.Tests/Database/DatabaseEntryRowTests.cs @@ -238,34 +238,34 @@ public void Render_ReadyEnabledEntry_RendersTrashButton() } [Fact] - public void Render_ReadyEntryWithClassificationPending_ShowsDisabledToggle() + public void Render_ReadyEntry_ShowsToggle_AndNoBadge() { // Arrange var entry = MakeEntry(DatabaseStatus.Ready); // Act - var component = RenderRow(entry, isClassificationPending: true); + var component = RenderRow(entry); // Assert - var radios = component.FindAll(".toggle input[type='radio']"); - Assert.NotEmpty(radios); - Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); + Assert.Single(component.FindAll(".toggle")); + Assert.Empty(component.FindAll(".db-entry-badge")); + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".db-entry-upgrading")); } [Fact] - public void Render_ReadyEntry_ShowsToggle_AndNoBadge() + public void Render_ReadyEntryWithClassificationPending_ShowsDisabledToggle() { // Arrange var entry = MakeEntry(DatabaseStatus.Ready); // Act - var component = RenderRow(entry); + var component = RenderRow(entry, isClassificationPending: true); // Assert - Assert.Single(component.FindAll(".toggle")); - Assert.Empty(component.FindAll(".db-entry-badge")); - Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); - Assert.Empty(component.FindAll(".db-entry-upgrading")); + var radios = component.FindAll(".toggle input[type='radio']"); + Assert.NotEmpty(radios); + Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); } [Fact] diff --git a/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs b/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs index 041e2f0b..909d4082 100644 --- a/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs +++ b/tests/Unit/EventLogExpert.Components.Tests/Modals/DebugLogModalTests.cs @@ -217,13 +217,12 @@ await component.WaitForAssertionAsync(() => } [Fact] - public async Task DebugLogModal_CopyClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() + public async Task DebugLogModal_CopyClick_CallsCopyTextAsyncWithEnvironmentNewLineJoinedDisplayed() { // Arrange var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), - DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), }; @@ -232,25 +231,25 @@ public async Task DebugLogModal_CopyClickWhilePendingFilterPending_FlushesFilter var component = Render(); await component.WaitForAssertionAsync(() => - Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act — typed text doesn't apply until the 250ms debounce; Copy must flush first. - await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "error" }); + var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + + // Act await component.Find("button:contains('Copy')").ClickAsync(new MouseEventArgs()); - // Assert: clipboard receives only the entry whose Message contains "error". - await _clipboardService.Received(1).CopyTextAsync(lines[1]); - await _clipboardService.DidNotReceive().CopyTextAsync( - Arg.Is(s => s.Contains(Constants.DebugLogFirstMessage))); + // Assert + await _clipboardService.Received(1).CopyTextAsync(expectedContent); } [Fact] - public async Task DebugLogModal_CopyClick_CallsCopyTextAsyncWithEnvironmentNewLineJoinedDisplayed() + public async Task DebugLogModal_CopyClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() { // Arrange var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), }; @@ -259,15 +258,16 @@ public async Task DebugLogModal_CopyClick_CallsCopyTextAsyncWithEnvironmentNewLi var component = Render(); await component.WaitForAssertionAsync(() => - Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - - var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act + // Act — typed text doesn't apply until the 250ms debounce; Copy must flush first. + await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "error" }); await component.Find("button:contains('Copy')").ClickAsync(new MouseEventArgs()); - // Assert - await _clipboardService.Received(1).CopyTextAsync(expectedContent); + // Assert: clipboard receives only the entry whose Message contains "error". + await _clipboardService.Received(1).CopyTextAsync(lines[1]); + await _clipboardService.DidNotReceive().CopyTextAsync( + Arg.Is(s => s.Contains(Constants.DebugLogFirstMessage))); } [Fact] @@ -309,7 +309,7 @@ await component.WaitForAssertionAsync( } [Fact] - public async Task DebugLogModal_EmptyLogWithActiveFilter_StillShowsLogIsEmptyMessage() + public async Task DebugLogModal_EmptyLog_CopyButtonIsDisabled() { // Arrange _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); @@ -319,19 +319,14 @@ public async Task DebugLogModal_EmptyLogWithActiveFilter_StillShowsLogIsEmptyMes await component.WaitForAssertionAsync(() => Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act: type a filter while the log is empty - await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "any-search-text" }); + // Act + Assert + var copyButton = component.Find("button:contains('Copy')"); - // Assert — zero entries (not zero filtered); "No entries match filters" would mislead. - await component.WaitForAssertionAsync(() => - { - Assert.Contains("Log is Empty...", component.Markup); - Assert.DoesNotContain("No entries match filters.", component.Markup); - }); + Assert.True(copyButton.HasAttribute("disabled")); } [Fact] - public async Task DebugLogModal_EmptyLog_CopyButtonIsDisabled() + public async Task DebugLogModal_EmptyLog_ExportButtonIsDisabled() { // Arrange _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); @@ -342,52 +337,56 @@ await component.WaitForAssertionAsync(() => Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); // Act + Assert - var copyButton = component.Find("button:contains('Copy')"); + var exportButton = component.Find("button:contains('Export')"); - Assert.True(copyButton.HasAttribute("disabled")); + Assert.True(exportButton.HasAttribute("disabled")); } [Fact] - public async Task DebugLogModal_EmptyLog_ExportButtonIsDisabled() + public async Task DebugLogModal_EmptyLog_ShowsLogIsEmptyMessageAndZeroCounter() { // Arrange _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); + // Act var component = Render(); + // Assert await component.WaitForAssertionAsync(() => Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act + Assert - var exportButton = component.Find("button:contains('Export')"); - - Assert.True(exportButton.HasAttribute("disabled")); + Assert.Contains("Log is Empty...", component.Markup); } [Fact] - public async Task DebugLogModal_EmptyLog_ShowsLogIsEmptyMessageAndZeroCounter() + public async Task DebugLogModal_EmptyLogWithActiveFilter_StillShowsLogIsEmptyMessage() { // Arrange _fileLogger.LoadAsync(Arg.Any()).Returns(DebugLogUtils.ToAsyncEnumerable([])); - // Act var component = Render(); - // Assert await component.WaitForAssertionAsync(() => Assert.Equal("0 of 0 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - Assert.Contains("Log is Empty...", component.Markup); + // Act: type a filter while the log is empty + await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "any-search-text" }); + + // Assert — zero entries (not zero filtered); "No entries match filters" would mislead. + await component.WaitForAssertionAsync(() => + { + Assert.Contains("Log is Empty...", component.Markup); + Assert.DoesNotContain("No entries match filters.", component.Markup); + }); } [Fact] - public async Task DebugLogModal_ExportClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() + public async Task DebugLogModal_ExportClick_CallsSaveAsyncWithEnvironmentNewLineJoinedDisplayed() { // Arrange var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), - DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), }; @@ -404,29 +403,31 @@ public async Task DebugLogModal_ExportClickWhilePendingFilterPending_FlushesFilt var component = Render(); await component.WaitForAssertionAsync(() => - Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); + Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act: type a filter that drops two entries, then click Export before debounce expires. - await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "error" }); + var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + + // Act await component.Find("button:contains('Export')").ClickAsync(new MouseEventArgs()); // Assert await _fileSaveService.Received(1).SaveAsync( - Arg.Any(), + Arg.Is(name => name.StartsWith("debug-log-") && name.EndsWith(".log")), FileSaveFileTypes.Log, Arg.Any>()); Assert.NotNull(capturedWriter); - Assert.Equal(lines[1], await InvokeWriterAndDecodeAsync(capturedWriter)); + Assert.Equal(expectedContent, await InvokeWriterAndDecodeAsync(capturedWriter)); } [Fact] - public async Task DebugLogModal_ExportClick_CallsSaveAsyncWithEnvironmentNewLineJoinedDisplayed() + public async Task DebugLogModal_ExportClickWhilePendingFilterPending_FlushesFilterBeforeReadingDisplayed() { // Arrange var lines = new[] { DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage), + DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogErrorMessage), DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage), }; @@ -443,21 +444,20 @@ public async Task DebugLogModal_ExportClick_CallsSaveAsyncWithEnvironmentNewLine var component = Render(); await component.WaitForAssertionAsync(() => - Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - - var expectedContent = string.Join(Environment.NewLine, new[] { lines[1], lines[0] }); + Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); - // Act + // Act: type a filter that drops two entries, then click Export before debounce expires. + await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "error" }); await component.Find("button:contains('Export')").ClickAsync(new MouseEventArgs()); // Assert await _fileSaveService.Received(1).SaveAsync( - Arg.Is(name => name.StartsWith("debug-log-") && name.EndsWith(".log")), + Arg.Any(), FileSaveFileTypes.Log, Arg.Any>()); Assert.NotNull(capturedWriter); - Assert.Equal(expectedContent, await InvokeWriterAndDecodeAsync(capturedWriter)); + Assert.Equal(lines[1], await InvokeWriterAndDecodeAsync(capturedWriter)); } [Fact] @@ -589,7 +589,8 @@ await component.WaitForAssertionAsync( TimeSpan.FromSeconds(2)); // Act - var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement; + Assert.NotNull(levelDropdown); var errorOption = levelDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Error"); @@ -630,7 +631,8 @@ await component.WaitForAssertionAsync(() => // Act — type a string filter (debounce starts), then change the level dropdown before it elapses. await component.Find("input[aria-label='Filter messages']").InputAsync(new ChangeEventArgs { Value = "matching" }); - var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement; + Assert.NotNull(levelDropdown); var errorOption = levelDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Error"); @@ -660,7 +662,8 @@ await component.WaitForAssertionAsync(() => Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); // Act: click "Error" in the level value dropdown - var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement; + Assert.NotNull(levelDropdown); var errorOption = levelDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Error"); @@ -690,14 +693,16 @@ await component.WaitForAssertionAsync(() => Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); // Act: switch operator to "Multi Select" - var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement!; + var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement; + Assert.NotNull(operatorDropdown); var multiSelectOption = operatorDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Multi Select"); await multiSelectOption.MouseDownAsync(new MouseEventArgs()); // Then pick Error and Warning from the multi-select dropdown - var levelsDropdown = component.Find("input[aria-label='Levels']").ParentElement!; + var levelsDropdown = component.Find("input[aria-label='Levels']").ParentElement; + Assert.NotNull(levelsDropdown); var errorOption = levelsDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Error"); @@ -733,14 +738,16 @@ await component.WaitForAssertionAsync(() => Assert.Equal("3 of 3 entries", component.Find(".debug-log-footer-counter").TextContent.Trim())); // Act: switch operator to "Not Equal" - var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement!; + var operatorDropdown = component.Find("input[aria-label='Level operator']").ParentElement; + Assert.NotNull(operatorDropdown); var notEqualOption = operatorDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Not Equal"); await notEqualOption.MouseDownAsync(new MouseEventArgs()); // Then select "Information" as the value to exclude - var levelDropdown = component.Find("input[aria-label='Level']").ParentElement!; + var levelDropdown = component.Find("input[aria-label='Level']").ParentElement; + Assert.NotNull(levelDropdown); var informationOption = levelDropdown.QuerySelectorAll("[role='option']") .First(o => o.TextContent.Trim() == "Information"); diff --git a/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs b/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs index 64ae387a..37898391 100644 --- a/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs +++ b/tests/Unit/EventLogExpert.EventDbTool.Tests/RegexHelperTests.cs @@ -24,35 +24,34 @@ public void TryCreate_WhenPatternIsEmpty_ReturnsTrueAndNoRegex() } [Fact] - public void TryCreate_WhenPatternIsNull_ReturnsTrueAndNoRegex() + public void TryCreate_WhenPatternIsInvalid_ReturnsFalseLogsErrorAndYieldsNullRegex() { - // Arrange + // Arrange — unbalanced character class is a parse-time ArgumentException. var logger = Substitute.For(); // Act - var success = RegexHelper.TryCreate(null, logger, out var regex); + var success = RegexHelper.TryCreate("[unclosed", logger, out var regex); // Assert - Assert.True(success); + Assert.False(success); Assert.Null(regex); - logger.DidNotReceive().Error(Arg.Any()); + logger.Received(1).Error(Arg.Is(h => + h.ToString().Contains("Invalid --filter regex") && h.ToString().Contains("[unclosed"))); } [Fact] - public void TryCreate_WhenPatternIsValid_ReturnsTrueAndCaseInsensitiveRegex() + public void TryCreate_WhenPatternIsNull_ReturnsTrueAndNoRegex() { // Arrange var logger = Substitute.For(); // Act - var success = RegexHelper.TryCreate("microsoft-windows-.*", logger, out var regex); + var success = RegexHelper.TryCreate(null, logger, out var regex); - // Assert — case insensitivity is contractual: provider names like "Microsoft-Windows-AAD" must match. + // Assert Assert.True(success); - Assert.NotNull(regex); - Assert.Matches(regex, "Microsoft-Windows-AAD"); - Assert.Matches(regex, "MICROSOFT-WINDOWS-FOO"); - Assert.DoesNotMatch(regex, "OpenSSH"); + Assert.Null(regex); + logger.DidNotReceive().Error(Arg.Any()); } [Fact] @@ -72,18 +71,19 @@ public void TryCreate_WhenPatternIsValid_HasOneSecondMatchTimeout() } [Fact] - public void TryCreate_WhenPatternIsInvalid_ReturnsFalseLogsErrorAndYieldsNullRegex() + public void TryCreate_WhenPatternIsValid_ReturnsTrueAndCaseInsensitiveRegex() { - // Arrange — unbalanced character class is a parse-time ArgumentException. + // Arrange var logger = Substitute.For(); // Act - var success = RegexHelper.TryCreate("[unclosed", logger, out var regex); + var success = RegexHelper.TryCreate("microsoft-windows-.*", logger, out var regex); - // Assert - Assert.False(success); - Assert.Null(regex); - logger.Received(1).Error(Arg.Is(h => - h.ToString().Contains("Invalid --filter regex") && h.ToString().Contains("[unclosed"))); + // Assert — case insensitivity is contractual: provider names like "Microsoft-Windows-AAD" must match. + Assert.True(success); + Assert.NotNull(regex); + Assert.Matches(regex, "Microsoft-Windows-AAD"); + Assert.Matches(regex, "MICROSOFT-WINDOWS-FOO"); + Assert.DoesNotMatch(regex, "OpenSSH"); } } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Channels/LogChannelMethodsTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Channels/LogChannelMethodsTests.cs index 94188fda..011a656b 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Channels/LogChannelMethodsTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Channels/LogChannelMethodsTests.cs @@ -39,19 +39,19 @@ public void GetMenuPath_WhenLogNameContainsSpaces_ShouldKeepSegmentIntact() } [Fact] - public void GetMenuPath_WhenMicrosoftWindowsChannelHasHyphen_ShouldKeepChannelIntact() + public void GetMenuPath_WhenMicrosoftWindowsChannel_ShouldExpandToFourSegments() { - var path = LogChannelMethods.GetMenuPath("Microsoft-Windows-Kernel-Power/Thermal-Operational"); + var path = LogChannelMethods.GetMenuPath("Microsoft-Windows-AAD/Operational"); - Assert.Equal(["Microsoft", "Windows", "Kernel-Power", "Thermal-Operational"], path); + Assert.Equal(["Microsoft", "Windows", "AAD", "Operational"], path); } [Fact] - public void GetMenuPath_WhenMicrosoftWindowsChannel_ShouldExpandToFourSegments() + public void GetMenuPath_WhenMicrosoftWindowsChannelHasHyphen_ShouldKeepChannelIntact() { - var path = LogChannelMethods.GetMenuPath("Microsoft-Windows-AAD/Operational"); + var path = LogChannelMethods.GetMenuPath("Microsoft-Windows-Kernel-Power/Thermal-Operational"); - Assert.Equal(["Microsoft", "Windows", "AAD", "Operational"], path); + Assert.Equal(["Microsoft", "Windows", "Kernel-Power", "Thermal-Operational"], path); } [Fact] diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs index 291df175..062ebd08 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs @@ -92,18 +92,19 @@ public void ConvertVariant_WhenBooleanTrue_ShouldReturnTrue() } [Fact] - public void ConvertVariant_WhenByteArrayEmpty_ShouldReturnEmptyArray() + public void ConvertVariant_WhenByte_ShouldReturnByte() { // Arrange - var variant = CreateVariantWithCount(EvtVariantType.ByteArray, IntPtr.Zero, 0); + byte expectedValue = 255; + var variant = CreateVariant(EvtVariantType.Byte, expectedValue); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Empty((byte[])result); + Assert.IsType(result); + Assert.Equal(expectedValue, result); } [Fact] @@ -134,19 +135,18 @@ public void ConvertVariant_WhenByteArray_ShouldReturnByteArray() } [Fact] - public void ConvertVariant_WhenByte_ShouldReturnByte() + public void ConvertVariant_WhenByteArrayEmpty_ShouldReturnEmptyArray() { // Arrange - byte expectedValue = 255; - var variant = CreateVariant(EvtVariantType.Byte, expectedValue); + var variant = CreateVariantWithCount(EvtVariantType.ByteArray, IntPtr.Zero, 0); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(expectedValue, result); + Assert.IsType(result); + Assert.Empty((byte[])result); } [Theory] @@ -231,18 +231,19 @@ public void ConvertVariant_WhenGuid_ShouldReturnGuid() } [Fact] - public void ConvertVariant_WhenHexInt32ArrayEmpty_ShouldReturnEmptyArray() + public void ConvertVariant_WhenHexInt32_ShouldReturnInt32() { // Arrange - var variant = CreateVariantWithCount(EvtVariantType.HexInt32Array, IntPtr.Zero, 0); + int expectedValue = unchecked((int)0x1234ABCD); + var variant = CreateVariant(EvtVariantType.HexInt32, expectedValue); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Empty((int[])result); + Assert.IsType(result); + Assert.Equal(expectedValue, result); } [Fact] @@ -277,19 +278,18 @@ public void ConvertVariant_WhenHexInt32Array_ShouldReturnInt32Array() } [Fact] - public void ConvertVariant_WhenHexInt32_ShouldReturnInt32() + public void ConvertVariant_WhenHexInt32ArrayEmpty_ShouldReturnEmptyArray() { // Arrange - int expectedValue = unchecked((int)0x1234ABCD); - var variant = CreateVariant(EvtVariantType.HexInt32, expectedValue); + var variant = CreateVariantWithCount(EvtVariantType.HexInt32Array, IntPtr.Zero, 0); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(expectedValue, result); + Assert.IsType(result); + Assert.Empty((int[])result); } [Fact] @@ -463,18 +463,28 @@ public void ConvertVariant_WhenSizeT_ShouldReturnIntPtr() } [Fact] - public void ConvertVariant_WhenStringArrayEmpty_ShouldReturnEmptyArray() + public void ConvertVariant_WhenString_ShouldReturnString() { // Arrange - var variant = CreateVariantWithCount(EvtVariantType.StringArray, IntPtr.Zero, 0); + var testString = "Test String"; + IntPtr stringPtr = Marshal.StringToHGlobalUni(testString); - // Act - var result = NativeMethods.ConvertVariant(variant); + try + { + var variant = CreateVariant(EvtVariantType.String, stringPtr); - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Empty((string[])result); + // Act + var result = NativeMethods.ConvertVariant(variant); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(testString, result); + } + finally + { + Marshal.FreeHGlobal(stringPtr); + } } [Fact] @@ -518,28 +528,18 @@ public void ConvertVariant_WhenStringArray_ShouldReturnStringArray() } [Fact] - public void ConvertVariant_WhenString_ShouldReturnString() + public void ConvertVariant_WhenStringArrayEmpty_ShouldReturnEmptyArray() { // Arrange - var testString = "Test String"; - IntPtr stringPtr = Marshal.StringToHGlobalUni(testString); - - try - { - var variant = CreateVariant(EvtVariantType.String, stringPtr); + var variant = CreateVariantWithCount(EvtVariantType.StringArray, IntPtr.Zero, 0); - // Act - var result = NativeMethods.ConvertVariant(variant); + // Act + var result = NativeMethods.ConvertVariant(variant); - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(testString, result); - } - finally - { - Marshal.FreeHGlobal(stringPtr); - } + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Empty((string[])result); } [Fact] @@ -581,18 +581,19 @@ public void ConvertVariant_WhenSysTime_ShouldReturnDateTime() } [Fact] - public void ConvertVariant_WhenUInt16ArrayEmpty_ShouldReturnEmptyArray() + public void ConvertVariant_WhenUInt16_ShouldReturnUInt16() { // Arrange - var variant = CreateVariantWithCount(EvtVariantType.UInt16Array, IntPtr.Zero, 0); + ushort expectedValue = 65000; + var variant = CreateVariant(EvtVariantType.UInt16, expectedValue); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Empty((ushort[])result); + Assert.IsType(result); + Assert.Equal(expectedValue, result); } [Fact] @@ -627,34 +628,34 @@ public void ConvertVariant_WhenUInt16Array_ShouldReturnUInt16Array() } [Fact] - public void ConvertVariant_WhenUInt16_ShouldReturnUInt16() + public void ConvertVariant_WhenUInt16ArrayEmpty_ShouldReturnEmptyArray() { // Arrange - ushort expectedValue = 65000; - var variant = CreateVariant(EvtVariantType.UInt16, expectedValue); + var variant = CreateVariantWithCount(EvtVariantType.UInt16Array, IntPtr.Zero, 0); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(expectedValue, result); + Assert.IsType(result); + Assert.Empty((ushort[])result); } [Fact] - public void ConvertVariant_WhenUInt32ArrayEmpty_ShouldReturnEmptyArray() + public void ConvertVariant_WhenUInt32_ShouldReturnUInt32() { // Arrange - var variant = CreateVariantWithCount(EvtVariantType.UInt32Array, IntPtr.Zero, 0); + uint expectedValue = 4000000000; + var variant = CreateVariant(EvtVariantType.UInt32, expectedValue); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Empty((uint[])result); + Assert.IsType(result); + Assert.Equal(expectedValue, result); } [Fact] @@ -689,19 +690,18 @@ public void ConvertVariant_WhenUInt32Array_ShouldReturnUInt32Array() } [Fact] - public void ConvertVariant_WhenUInt32_ShouldReturnUInt32() + public void ConvertVariant_WhenUInt32ArrayEmpty_ShouldReturnEmptyArray() { // Arrange - uint expectedValue = 4000000000; - var variant = CreateVariant(EvtVariantType.UInt32, expectedValue); + var variant = CreateVariantWithCount(EvtVariantType.UInt32Array, IntPtr.Zero, 0); // Act var result = NativeMethods.ConvertVariant(variant); // Assert Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(expectedValue, result); + Assert.IsType(result); + Assert.Empty((uint[])result); } [Fact] diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs index fb35fe26..cb14f59a 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/CompressedJsonValueConverterTests.cs @@ -137,8 +137,9 @@ public void ConvertToCompressedJson_WithNestedObjects_ShouldRoundTrip() // Assert Assert.NotNull(decompressed); Assert.Equal(originalData.Id, decompressed.Id); + Assert.NotNull(originalData.Child); Assert.NotNull(decompressed.Child); - Assert.Equal(originalData.Child!.Name, decompressed.Child.Name); + Assert.Equal(originalData.Child.Name, decompressed.Child.Name); Assert.Equal(originalData.Child.Value, decompressed.Child.Value); Assert.Equal(originalData.Child.Items, decompressed.Child.Items); } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderJsonContextTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderJsonContextTests.cs index 6bd7ac3a..6a0e43ea 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderJsonContextTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/ProviderDatabase/ProviderJsonContextTests.cs @@ -8,8 +8,8 @@ namespace EventLogExpert.Eventing.Tests.ProviderDatabase; public sealed class ProviderJsonContextTests { - public static TheoryData SourceGeneratedTypes => new() - { + public static TheoryData SourceGeneratedTypes => + [ typeof(MessageModel), typeof(EventModel), typeof(IReadOnlyList), @@ -21,7 +21,7 @@ public sealed class ProviderJsonContextTests typeof(List), typeof(Dictionary), typeof(Dictionary), - }; + ]; [Theory] [MemberData(nameof(SourceGeneratedTypes))] @@ -30,7 +30,7 @@ public void Default_ShouldProvideMetadataFor_ProviderDtoType(Type type) var typeInfo = ProviderJsonContext.Default.GetTypeInfo(type); Assert.NotNull(typeInfo); - Assert.Equal(type, typeInfo!.Type); + Assert.Equal(type, typeInfo.Type); } [Fact] diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs index 64936184..6f995d9c 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverBaseTests.cs @@ -251,19 +251,19 @@ public void ResolveEvent_WithAmbiguousPrimaryAndSupplementalModernEventTask_Shou } [Fact] - public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplementalMatch_ShouldUseSupplementalDescription() + public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplemental_ShouldAlsoUseSupplementalForKeywordsAndTask() { - // Arrange - primary has 2 legacy messages for the same event ID with no LogLink - // and identical severity (so disambiguation fails). Supplemental has a matching - // modern event. Should fall back to supplemental's description. + // Arrange - regression for the cross-issue case: when primary has ambiguous legacy + // messages AND supplemental is loaded for the description fallback, supplemental's + // task and keyword metadata must also be visible to ResolveTaskName / GetKeywordsFromBitmask. var primaryDetails = new ProviderDetails { ProviderName = Constants.TestProviderName, Events = [], Messages = [ - new MessageModel { ShortId = 500, RawId = 0x40000500, Text = Constants.PrimaryMessageA, LogLink = null }, - new MessageModel { ShortId = 500, RawId = 0x40000500, Text = Constants.PrimaryMessageB, LogLink = null } + new MessageModel { ShortId = 600, RawId = 0x40000600, Text = Constants.PrimaryMessageA, LogLink = null }, + new MessageModel { ShortId = 600, RawId = 0x40000600, Text = Constants.PrimaryMessageB, LogLink = null } ], Parameters = [], Keywords = new Dictionary(), @@ -278,19 +278,20 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplementalMatch_ShouldUs [ new EventModel { - Id = 500, + Id = 600, Version = 0, + Task = 9, LogName = Constants.ApplicationLogName, - Description = "Supplemental modern: %1", + Description = Constants.SupplementalDescription, Keywords = [], - Template = "" + Template = Constants.EmptyTemplate } ], Messages = [], Parameters = [], - Keywords = new Dictionary(), + Keywords = new Dictionary { { 0x4, Constants.SupplementalKeyword } }, Opcodes = new Dictionary(), - Tasks = new Dictionary() + Tasks = new Dictionary { { 9, Constants.SupplementalTask } } }; var resolver = new SupplementalTestResolver([primaryDetails], supplementalDetails); @@ -298,11 +299,12 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplementalMatch_ShouldUs var eventRecord = new EventRecord { ProviderName = Constants.TestProviderName, - Id = 500, + Id = 600, Version = 0, + Task = 9, Level = 4, LogName = Constants.ApplicationLogName, - Properties = ["resolved"] + Keywords = 0x4 }; // Act @@ -310,23 +312,25 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplementalMatch_ShouldUs // Assert Assert.NotNull(displayEvent); - Assert.Contains("Supplemental modern: resolved", displayEvent.Description); + Assert.Contains(Constants.SupplementalDescription, displayEvent.Description); + Assert.Equal(Constants.SupplementalTask, displayEvent.TaskCategory); + Assert.Contains(Constants.SupplementalKeyword, displayEvent.Keywords); } [Fact] - public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplemental_ShouldAlsoUseSupplementalForKeywordsAndTask() + public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplementalMatch_ShouldUseSupplementalDescription() { - // Arrange - regression for the cross-issue case: when primary has ambiguous legacy - // messages AND supplemental is loaded for the description fallback, supplemental's - // task and keyword metadata must also be visible to ResolveTaskName / GetKeywordsFromBitmask. + // Arrange - primary has 2 legacy messages for the same event ID with no LogLink + // and identical severity (so disambiguation fails). Supplemental has a matching + // modern event. Should fall back to supplemental's description. var primaryDetails = new ProviderDetails { ProviderName = Constants.TestProviderName, Events = [], Messages = [ - new MessageModel { ShortId = 600, RawId = 0x40000600, Text = Constants.PrimaryMessageA, LogLink = null }, - new MessageModel { ShortId = 600, RawId = 0x40000600, Text = Constants.PrimaryMessageB, LogLink = null } + new MessageModel { ShortId = 500, RawId = 0x40000500, Text = Constants.PrimaryMessageA, LogLink = null }, + new MessageModel { ShortId = 500, RawId = 0x40000500, Text = Constants.PrimaryMessageB, LogLink = null } ], Parameters = [], Keywords = new Dictionary(), @@ -341,20 +345,19 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplemental_ShouldAlsoUse [ new EventModel { - Id = 600, + Id = 500, Version = 0, - Task = 9, LogName = Constants.ApplicationLogName, - Description = Constants.SupplementalDescription, + Description = "Supplemental modern: %1", Keywords = [], - Template = Constants.EmptyTemplate + Template = "" } ], Messages = [], Parameters = [], - Keywords = new Dictionary { { 0x4, Constants.SupplementalKeyword } }, + Keywords = new Dictionary(), Opcodes = new Dictionary(), - Tasks = new Dictionary { { 9, Constants.SupplementalTask } } + Tasks = new Dictionary() }; var resolver = new SupplementalTestResolver([primaryDetails], supplementalDetails); @@ -362,12 +365,11 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplemental_ShouldAlsoUse var eventRecord = new EventRecord { ProviderName = Constants.TestProviderName, - Id = 600, + Id = 500, Version = 0, - Task = 9, Level = 4, LogName = Constants.ApplicationLogName, - Keywords = 0x4 + Properties = ["resolved"] }; // Act @@ -375,9 +377,7 @@ public void ResolveEvent_WithAmbiguousPrimaryLegacyAndSupplemental_ShouldAlsoUse // Assert Assert.NotNull(displayEvent); - Assert.Contains(Constants.SupplementalDescription, displayEvent.Description); - Assert.Equal(Constants.SupplementalTask, displayEvent.TaskCategory); - Assert.Contains(Constants.SupplementalKeyword, displayEvent.Keywords); + Assert.Contains("Supplemental modern: resolved", displayEvent.Description); } [Fact] @@ -496,8 +496,9 @@ public void ResolveEvent_WithClassicEventIdMatchingWin32ErrorCode_ShouldNotPrepe // Compare against the locale's system-message text for error 2 so the test // catches false matches on non-English Windows too. var win32ErrorTwoMessage = NativeMethods.FormatSystemMessage(2); + Assert.NotNull(win32ErrorTwoMessage); Assert.False(string.IsNullOrWhiteSpace(win32ErrorTwoMessage), "FormatSystemMessage(2) must return text on Windows."); - Assert.DoesNotContain(win32ErrorTwoMessage!, displayEvent.Description); + Assert.DoesNotContain(win32ErrorTwoMessage, displayEvent.Description); // Property tail is still rendered Assert.Contains("The following information was included with the event:", displayEvent.Description); Assert.Contains("payload-a", displayEvent.Description); @@ -539,8 +540,9 @@ public void ResolveEvent_WithClassicEventIdZeroAndNoProviderMetadata_ShouldPrepe // Compare against the locale's system-message text rather than a hard-coded English // string so the test passes on non-English Windows. var expectedSystemMessage = NativeMethods.FormatSystemMessage(0); + Assert.NotNull(expectedSystemMessage); Assert.False(string.IsNullOrWhiteSpace(expectedSystemMessage), "FormatSystemMessage(0) must return text on Windows."); - Assert.StartsWith(expectedSystemMessage!, displayEvent.Description); + Assert.StartsWith(expectedSystemMessage, displayEvent.Description); Assert.Contains("The following information was included with the event:", displayEvent.Description); Assert.Contains("AsusUpdateCheck", displayEvent.Description); Assert.Contains("CServiceControl in OnStop", displayEvent.Description); @@ -595,44 +597,6 @@ public void ResolveEvent_WithDataSourceTag_ShouldNotMatchAsDataElement() Assert.DoesNotContain("Failed to resolve", displayEvent.Description); } - [Fact] - public void ResolveEvent_WithEmptyPrimaryAndNoSupplemental_ShouldReturnEventDataTail() - { - // Arrange - empty primary AND no supplemental should fall through to the - // no-metadata fallback. With multiple properties present, that surfaces them - // under the "included with the event" header (mmc-style payload dump). - var primaryDetails = new ProviderDetails - { - ProviderName = Constants.TestProviderName, - Events = [], - Messages = [], - Parameters = [], - Keywords = new Dictionary(), - Opcodes = new Dictionary(), - Tasks = new Dictionary() - }; - - var resolver = new SupplementalTestResolver([primaryDetails], supplemental: null); - - var eventRecord = new EventRecord - { - ProviderName = Constants.TestProviderName, - Id = 9999, - Properties = ["a", "b", "c"] - }; - - // Act - var displayEvent = resolver.ResolveEvent(eventRecord); - - // Assert - Assert.NotNull(displayEvent); - Assert.Contains("The following information was included with the event:", displayEvent.Description); - Assert.Contains("a", displayEvent.Description); - Assert.Contains("b", displayEvent.Description); - Assert.Contains("c", displayEvent.Description); - Assert.DoesNotContain("No matching message", displayEvent.Description); - } - [Fact] public void ResolveEvent_WithEmptyPrimaryAndNonMatchingSupplemental_ShouldReturnNoMatchingMessage() { @@ -692,6 +656,44 @@ public void ResolveEvent_WithEmptyPrimaryAndNonMatchingSupplemental_ShouldReturn Assert.DoesNotContain("Failed to resolve", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithEmptyPrimaryAndNoSupplemental_ShouldReturnEventDataTail() + { + // Arrange - empty primary AND no supplemental should fall through to the + // no-metadata fallback. With multiple properties present, that surfaces them + // under the "included with the event" header (mmc-style payload dump). + var primaryDetails = new ProviderDetails + { + ProviderName = Constants.TestProviderName, + Events = [], + Messages = [], + Parameters = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + + var resolver = new SupplementalTestResolver([primaryDetails], supplemental: null); + + var eventRecord = new EventRecord + { + ProviderName = Constants.TestProviderName, + Id = 9999, + Properties = ["a", "b", "c"] + }; + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains("The following information was included with the event:", displayEvent.Description); + Assert.Contains("a", displayEvent.Description); + Assert.Contains("b", displayEvent.Description); + Assert.Contains("c", displayEvent.Description); + Assert.DoesNotContain("No matching message", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithEmptyPrimaryAndSupplementalModernDescription_ShouldUsePrimaryParametersFallback() { @@ -961,26 +963,6 @@ public void ResolveEvent_WithFormattingCharacters_ShouldCleanupDescription() Assert.Contains("\t", displayEvent.Description); } - [Fact] - public void ResolveEvent_WithHResultOutType_ShouldResolveDynamically() - { - // Arrange - win:HResult with a common error code should resolve dynamically - var (details, eventRecord) = EventUtils.CreateModernEvent( - description: "Error: %1", - template: """""", - properties: [unchecked((int)0x80070005)]); - - var resolver = new TestEventResolver([details]); - - // Act - var displayEvent = resolver.ResolveEvent(eventRecord); - - // Assert - 0x80070005 is ACCESS_DENIED; resolved message should not contain the raw hex code - Assert.NotNull(displayEvent); - Assert.DoesNotContain("0x80070005", displayEvent.Description); - Assert.DoesNotContain("0x00000005", displayEvent.Description); - } - [Fact] public void ResolveEvent_WithHexOutTypeAfterMissingOutType_ShouldFormatCorrectly() { @@ -1086,6 +1068,26 @@ public void ResolveEvent_WithHiddenLengthField_ShouldAlignOutTypesCorrectly() Assert.Contains("TestName", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithHResultOutType_ShouldResolveDynamically() + { + // Arrange - win:HResult with a common error code should resolve dynamically + var (details, eventRecord) = EventUtils.CreateModernEvent( + description: "Error: %1", + template: """""", + properties: [unchecked((int)0x80070005)]); + + var resolver = new TestEventResolver([details]); + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert - 0x80070005 is ACCESS_DENIED; resolved message should not contain the raw hex code + Assert.NotNull(displayEvent); + Assert.DoesNotContain("0x80070005", displayEvent.Description); + Assert.DoesNotContain("0x00000005", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithLargeTemplate_ShouldNotOverflowStack() { @@ -1763,59 +1765,6 @@ public void ResolveEvent_WithMultipleDataNodesOnOneLine_ShouldCountAllElements() Assert.DoesNotContain("Failed to resolve", displayEvent.Description); } - [Fact] - public void ResolveEvent_WithMultipleLegacyMessagesSameSeverity_ShouldDisambiguateByQualifier() - { - // Arrange - reproduces the Microsoft-Windows-Defrag EventID 258 case where two - // messages share the same EventId and severity but differ in their high 16 bits - // (Qualifier). Windows identifies the right entry by full message ID, so - // RawId == (Qualifiers << 16) | EventId. - var providerDetails = new ProviderDetails - { - ProviderName = Constants.TestProviderName, - Events = [], - Messages = - [ - new MessageModel - { - ProviderName = Constants.TestProviderName, - ShortId = 258, - RawId = 0x00000102, // Qualifier=0, severity 00=Success - Text = "The storage optimizer successfully completed %1 on %2" - }, - new MessageModel - { - ProviderName = Constants.TestProviderName, - ShortId = 258, - RawId = 0x09000102, // Qualifier=0x900, severity 00=Success - Text = "The retrim operation was skipped" - } - ], - Parameters = [], - Keywords = new Dictionary(), - Tasks = new Dictionary() - }; - - var resolver = new TestEventResolver([providerDetails]); - - var eventRecord = new EventRecord - { - ProviderName = Constants.TestProviderName, - Id = 258, - Qualifiers = 0, - Level = 0, - LogName = Constants.ApplicationLogName, - Properties = ["defragmentation", "Data (E:)"] - }; - - // Act - var displayEvent = resolver.ResolveEvent(eventRecord); - - // Assert - Assert.NotNull(displayEvent); - Assert.Equal("The storage optimizer successfully completed defragmentation on Data (E:)", displayEvent.Description); - } - [Fact] public void ResolveEvent_WithMultipleLegacyMessages_ShouldDisambiguateByLogLink() { @@ -1951,6 +1900,59 @@ public void ResolveEvent_WithMultipleLegacyMessages_ShouldReturnDefaultDescripti Assert.Contains("No matching message", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithMultipleLegacyMessagesSameSeverity_ShouldDisambiguateByQualifier() + { + // Arrange - reproduces the Microsoft-Windows-Defrag EventID 258 case where two + // messages share the same EventId and severity but differ in their high 16 bits + // (Qualifier). Windows identifies the right entry by full message ID, so + // RawId == (Qualifiers << 16) | EventId. + var providerDetails = new ProviderDetails + { + ProviderName = Constants.TestProviderName, + Events = [], + Messages = + [ + new MessageModel + { + ProviderName = Constants.TestProviderName, + ShortId = 258, + RawId = 0x00000102, // Qualifier=0, severity 00=Success + Text = "The storage optimizer successfully completed %1 on %2" + }, + new MessageModel + { + ProviderName = Constants.TestProviderName, + ShortId = 258, + RawId = 0x09000102, // Qualifier=0x900, severity 00=Success + Text = "The retrim operation was skipped" + } + ], + Parameters = [], + Keywords = new Dictionary(), + Tasks = new Dictionary() + }; + + var resolver = new TestEventResolver([providerDetails]); + + var eventRecord = new EventRecord + { + ProviderName = Constants.TestProviderName, + Id = 258, + Qualifiers = 0, + Level = 0, + LogName = Constants.ApplicationLogName, + Properties = ["defragmentation", "Data (E:)"] + }; + + // Act + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Equal("The storage optimizer successfully completed defragmentation on Data (E:)", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithMultipleLengthPrefixedBinaryPairs_ShouldMatchTemplate() { @@ -2052,29 +2054,6 @@ public void ResolveEvent_WithNewlineSeparatedDataAttributes_ShouldCountAllElemen Assert.DoesNotContain("Failed to resolve", displayEvent.Description); } - [Fact] - public void ResolveEvent_WithNoProviderDetails_ShouldReturnDefaultDescription() - { - // Arrange - var resolver = new TestEventResolver(); - - var eventRecord = new EventRecord - { - ProviderName = Constants.NonExistentProviderName, - Id = 1000, - ComputerName = Constants.TestComputer, - LogName = Constants.ApplicationLogName - }; - - // Act - resolver.LoadProviderDetails(eventRecord); - var displayEvent = resolver.ResolveEvent(eventRecord); - - // Assert - Assert.NotNull(displayEvent); - Assert.Contains("No matching provider", displayEvent.Description); - } - [Fact] public void ResolveEvent_WithNonClassicEventIdZeroAndNoProviderMetadata_ShouldNotPrependSystemMessage() { @@ -2118,6 +2097,29 @@ public void ResolveEvent_WithNonClassicEventIdZeroAndNoProviderMetadata_ShouldNo Assert.Contains("payload-a", displayEvent.Description); } + [Fact] + public void ResolveEvent_WithNoProviderDetails_ShouldReturnDefaultDescription() + { + // Arrange + var resolver = new TestEventResolver(); + + var eventRecord = new EventRecord + { + ProviderName = Constants.NonExistentProviderName, + Id = 1000, + ComputerName = Constants.TestComputer, + LogName = Constants.ApplicationLogName + }; + + // Act + resolver.LoadProviderDetails(eventRecord); + var displayEvent = resolver.ResolveEvent(eventRecord); + + // Assert + Assert.NotNull(displayEvent); + Assert.Contains("No matching provider", displayEvent.Description); + } + [Fact] public void ResolveEvent_WithNormalMismatch_ShouldStillReject() { @@ -2808,9 +2810,9 @@ public void ResolveEvent_WithProviderHavingOnlyKeywordsTasksOpcodes_ShouldReturn } [Fact] - public void ResolveEvent_WithProviderKeywordsInBits32To47_ShouldResolveKeywords() + public void ResolveEvent_WithProviderKeywords_ShouldResolveKeywords() { - // Arrange - keyword in bit 32 (0x100000000), which was previously masked out + // Arrange var providerDetails = new ProviderDetails { ProviderName = Constants.TestProviderName, @@ -2819,8 +2821,8 @@ public void ResolveEvent_WithProviderKeywordsInBits32To47_ShouldResolveKeywords( Parameters = [], Keywords = new Dictionary { - { 0x100000000L, "HighBitKeyword" }, - { 0x1, "LowBitKeyword" } + { 0x1, "CustomKeyword1" }, + { 0x2, "CustomKeyword2" } }, Tasks = new Dictionary() }; @@ -2831,7 +2833,7 @@ public void ResolveEvent_WithProviderKeywordsInBits32To47_ShouldResolveKeywords( { ProviderName = Constants.TestProviderName, Id = 1000, - Keywords = 0x100000001L // Both high and low provider keywords + Keywords = 0x3 // Both custom keywords }; // Act @@ -2839,14 +2841,14 @@ public void ResolveEvent_WithProviderKeywordsInBits32To47_ShouldResolveKeywords( // Assert Assert.NotNull(displayEvent); - Assert.Contains("HighBitKeyword", displayEvent.Keywords); - Assert.Contains("LowBitKeyword", displayEvent.Keywords); + Assert.Contains("CustomKeyword1", displayEvent.Keywords); + Assert.Contains("CustomKeyword2", displayEvent.Keywords); } [Fact] - public void ResolveEvent_WithProviderKeywords_ShouldResolveKeywords() + public void ResolveEvent_WithProviderKeywordsInBits32To47_ShouldResolveKeywords() { - // Arrange + // Arrange - keyword in bit 32 (0x100000000), which was previously masked out var providerDetails = new ProviderDetails { ProviderName = Constants.TestProviderName, @@ -2855,8 +2857,8 @@ public void ResolveEvent_WithProviderKeywords_ShouldResolveKeywords() Parameters = [], Keywords = new Dictionary { - { 0x1, "CustomKeyword1" }, - { 0x2, "CustomKeyword2" } + { 0x100000000L, "HighBitKeyword" }, + { 0x1, "LowBitKeyword" } }, Tasks = new Dictionary() }; @@ -2867,7 +2869,7 @@ public void ResolveEvent_WithProviderKeywords_ShouldResolveKeywords() { ProviderName = Constants.TestProviderName, Id = 1000, - Keywords = 0x3 // Both custom keywords + Keywords = 0x100000001L // Both high and low provider keywords }; // Act @@ -2875,8 +2877,8 @@ public void ResolveEvent_WithProviderKeywords_ShouldResolveKeywords() // Assert Assert.NotNull(displayEvent); - Assert.Contains("CustomKeyword1", displayEvent.Keywords); - Assert.Contains("CustomKeyword2", displayEvent.Keywords); + Assert.Contains("HighBitKeyword", displayEvent.Keywords); + Assert.Contains("LowBitKeyword", displayEvent.Keywords); } [Fact] diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs index 55f01a10..0677c12e 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Resolvers/EventResolverCacheTests.cs @@ -9,9 +9,6 @@ public sealed class EventResolverCacheTests { public enum CacheKind { Description, Value } - private static string GetOrAdd(EventResolverCache cache, CacheKind kind, string input) => - kind == CacheKind.Description ? cache.GetOrAddDescription(input) : cache.GetOrAddValue(input); - [Fact] public void ClearAll_AfterAddingItems_ShouldClearBothCaches() { @@ -264,4 +261,7 @@ public void SeparateCaches_DescriptionAndValue_ShouldNotInterfere() Assert.Equal(sharedString, description); Assert.Equal(sharedString, value); } + + private static string GetOrAdd(EventResolverCache cache, CacheKind kind, string input) => + kind == CacheKind.Description ? cache.GetOrAddDescription(input) : cache.GetOrAddValue(input); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Alerts/AlertDialogServiceTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Alerts/AlertDialogServiceTests.cs index c9196b5c..cabf6f8a 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Alerts/AlertDialogServiceTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Alerts/AlertDialogServiceTests.cs @@ -97,6 +97,31 @@ public async Task DisplayPrompt_WhenNoActiveHost_ShouldCallStandalonePromptOpene Assert.Equal("old-value", capturedPrompt["InitialValue"]); } + [Fact] + public async Task ShowAlert_ShouldMarshalThroughMainThreadService() + { + // Arrange — capture that MainThread invocation happens before the routing decision runs. + var broker = Substitute.For(); + broker.TryGet(out Arg.Any()).Returns(false); + + var mainThread = Substitute.For(); + mainThread.InvokeOnMainThreadAsync(Arg.Any>()) + .Returns(call => ((Func)call[0])()); + + var sut = new AlertDialogService( + broker, + mainThread, + Substitute.For(), + _ => Task.FromResult(true), + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowAlert("t", "m", "c"); + + // Assert + await mainThread.Received(1).InvokeOnMainThreadAsync(Arg.Any>()); + } + [Fact] public async Task ShowAlertOneButton_BannerPresentation_DoesNotMarshalThroughMainThreadService() { @@ -383,31 +408,6 @@ public async Task ShowAlertTwoButton_WhenInlineCancelled_ShouldReturnFalse() Assert.False(result); } - [Fact] - public async Task ShowAlert_ShouldMarshalThroughMainThreadService() - { - // Arrange — capture that MainThread invocation happens before the routing decision runs. - var broker = Substitute.For(); - broker.TryGet(out Arg.Any()).Returns(false); - - var mainThread = Substitute.For(); - mainThread.InvokeOnMainThreadAsync(Arg.Any>()) - .Returns(call => ((Func)call[0])()); - - var sut = new AlertDialogService( - broker, - mainThread, - Substitute.For(), - _ => Task.FromResult(true), - _ => Task.FromResult(string.Empty)); - - // Act - await sut.ShowAlert("t", "m", "c"); - - // Assert - await mainThread.Received(1).InvokeOnMainThreadAsync(Arg.Any>()); - } - [Fact] public async Task ShowErrorAlert_DoesNotMarshalThroughMainThreadService() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Banner/BannerServiceTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Banner/BannerServiceTests.cs index 964dd579..5cce3af7 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Banner/BannerServiceTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Banner/BannerServiceTests.cs @@ -49,7 +49,9 @@ public void BannerProgressEntry_Cancel_InvokesUnderlyingEventArgsCancel() new UpgradeBatchStartedEventArgs(batchId, UpgradeProgressScope.Background, 1, cts)); // Act - sut.BackgroundProgress!.Cancel(); + var progress = sut.BackgroundProgress; + Assert.NotNull(progress); + progress.Cancel(); // Assert Assert.True(cts.IsCancellationRequested); @@ -892,7 +894,9 @@ public void UpgradeBatchProgress_RefreshesQueuedBatchesAfterFromDatabaseService( databaseService.UpgradeBatchStarted += Raise.EventWith( databaseService, new UpgradeBatchStartedEventArgs(batchId, UpgradeProgressScope.Background, 1, cts)); - Assert.Equal(0, sut.BackgroundProgress!.QueuedBatchesAfter); + var initial = sut.BackgroundProgress; + Assert.NotNull(initial); + Assert.Equal(0, initial.QueuedBatchesAfter); // Act — another batch enqueues; Progress event picks up the new count. databaseService.QueuedBatchCount.Returns(3); @@ -901,7 +905,9 @@ public void UpgradeBatchProgress_RefreshesQueuedBatchesAfterFromDatabaseService( new UpgradeBatchProgressEventArgs(batchId, 1, "first.db", UpgradePhase.BackingUp)); // Assert - Assert.Equal(3, sut.BackgroundProgress.QueuedBatchesAfter); + var after = sut.BackgroundProgress; + Assert.NotNull(after); + Assert.Equal(3, after.QueuedBatchesAfter); } [Fact] diff --git a/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs b/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs index 84b5972c..50cf2003 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs @@ -439,7 +439,7 @@ public async Task HandleLoadNewEvents_WhenBufferSpansMultipleLogs_ShouldGroupInt // Assert: a single batched append covering both logs. mockDispatcher.Received(1).Dispatch(Arg.Any()); Assert.NotNull(captured); - Assert.Equal(2, captured!.EventsByLog.Count); + Assert.Equal(2, captured.EventsByLog.Count); Assert.Equal(2, captured.EventsByLog[applicationLog.Id].Count); Assert.Single(captured.EventsByLog[testLog.Id]); }