From 90ef6e8dc58180d5f7f6da53e16f1b9234ef6837 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 15:20:03 -0400 Subject: [PATCH] test(playwright): add ConfigAuditDataSeeder (AuditLogEntries direct-SQL seeder) (Wave 3) --- .../Audit/ConfigAuditDataSeeder.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs new file mode 100644 index 00000000..c024f405 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs @@ -0,0 +1,170 @@ +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; + +/// +/// Direct-SQL seeding helper for the Configuration Audit Log Playwright E2E tests +/// (Wave 3). The /audit/configuration page reads the AuditLogEntries +/// table (entity ) +/// via CentralUiRepository — a different table from the canonical +/// AuditLog table that writes — so this seeder +/// targets AuditLogEntries specifically. +/// +/// +/// Mirrors 's connection handling and best-effort +/// cleanup idiom: it opens a to +/// (the running Docker +/// cluster's ScadaBridgeConfig DB), inserts its own rows at setup time, and +/// best-effort deletes them at teardown. +/// +/// +/// +/// Every seeded row carries EntityType = marker (a unique per-test prefix +/// derived from the test name + a GUID), so a UI filter on Entity Type isolates +/// exactly this run's rows for deterministic pagination at 50/page, and the +/// teardown DELETE never touches rows the cluster itself produced. Column +/// names/types are verified against ScadaBridgeDbContextModelSnapshot: +/// AuditLogEntries with Id (int identity), User/Action/ +/// EntityType/EntityId/EntityName (nvarchar, required), +/// AfterStateJson (nvarchar(max), nullable), Timestamp +/// (datetimeoffset), BundleImportId (uniqueidentifier, nullable). +/// +/// +internal static class ConfigAuditDataSeeder +{ + /// + /// Connection string for the running cluster's configuration DB. + /// Delegates to . + /// + public static string ConnectionString => PlaywrightDbConnection.ConnectionString; + + private const string InsertSql = @" +INSERT INTO [AuditLogEntries] +([User],[Action],[EntityType],[EntityId],[EntityName],[AfterStateJson],[Timestamp],[BundleImportId]) +VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @bundleId);"; + + /// + /// Probe whether the configuration DB is reachable. Tests gate their per-test + /// setup on this; when the cluster is down the test fails with a clear + /// "MSSQL unavailable" message instead of an opaque SqlException. Mirrors + /// . + /// + public static async Task IsAvailableAsync() + { + try + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + return true; + } + catch + { + return false; + } + } + + /// + /// Seeds rows into AuditLogEntries. Inserts + /// bulk rows (all EntityType = marker, BundleImportId = NULL) plus + /// bundle rows (also EntityType = marker, + /// but BundleImportId = bundleId). Row index 0 of the bulk set carries a + /// > 1024-char AfterStateJson to drive the large-state modal on the + /// page; the rest carry a tiny JSON blob. Timestamps step back one second per + /// row from "now" so rows are recent and ordered. + /// + /// Unique per-test prefix; isolates this run's rows. + /// Bundle import id stamped on the extra bundle rows. + /// Number of bulk rows (default 55 — spans two pages at 50/page). + /// Number of extra rows carrying the bundle import id (default 2). + public static async Task SeedAsync(string marker, Guid bundleId, int bulkCount = 55, int bundleRows = 2) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + // Bulk rows: all EntityType = marker, BundleImportId NULL. Row 0 gets a + // large AfterStateJson (> 1024 chars) to drive the large-state modal. + for (var i = 0; i < bulkCount; i++) + { + var afterState = i == 0 + ? "{\"blob\":\"" + new string('x', 1100) + "\"}" + : "{\"k\":\"v\"}"; + + await InsertRowAsync( + connection, + user: marker + "-user", + action: "Update", + entityType: marker, + entityId: marker + "-eid-" + i, + entityName: marker + "-" + i, + afterStateJson: afterState, + timestamp: DateTimeOffset.UtcNow.AddSeconds(-i), + bundleImportId: null); + } + + // Bundle rows: EntityType = marker (so DeleteByMarkerAsync cleans them too) + // AND BundleImportId = bundleId, to drive the ?bundleImportId= chip drill-in. + for (var i = 0; i < bundleRows; i++) + { + await InsertRowAsync( + connection, + user: marker + "-user", + action: "Update", + entityType: marker, + entityId: marker + "-eid-bundle-" + i, + entityName: marker + "-bundle-" + i, + afterStateJson: "{\"k\":\"v\"}", + timestamp: DateTimeOffset.UtcNow.AddSeconds(-(bulkCount + i)), + bundleImportId: bundleId); + } + } + + /// + /// Best-effort cleanup. Deletes every AuditLogEntries row whose + /// EntityType equals . Swallows all errors — the + /// marker carries a GUID so the rows are unique to this test run and tests + /// should not fail teardown. Mirrors + /// . + /// + public static async Task DeleteByMarkerAsync(string marker) + { + try + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM [AuditLogEntries] WHERE [EntityType] = @marker"; + cmd.Parameters.AddWithValue("@marker", marker); + await cmd.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort — the marker carries a GUID so the rows are unique to + // this test run and won't collide on the next pass. + } + } + + private static async Task InsertRowAsync( + SqlConnection connection, + string user, + string action, + string entityType, + string entityId, + string entityName, + string afterStateJson, + DateTimeOffset timestamp, + Guid? bundleImportId) + { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = InsertSql; + cmd.Parameters.AddWithValue("@user", user); + cmd.Parameters.AddWithValue("@action", action); + cmd.Parameters.AddWithValue("@entityType", entityType); + cmd.Parameters.AddWithValue("@entityId", entityId); + cmd.Parameters.AddWithValue("@entityName", entityName); + cmd.Parameters.AddWithValue("@afterState", (object?)afterStateJson ?? DBNull.Value); + cmd.Parameters.AddWithValue("@ts", timestamp); + cmd.Parameters.AddWithValue("@bundleId", (object?)bundleImportId ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + } +}