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(); } }