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