using Microsoft.Data.SqlClient; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; /// /// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests /// (Notification Outbox #21). /// /// /// The Notification Report page reads the central Notifications table through the /// NotificationOutboxActor singleton. Its query path /// (NotificationOutboxQueryRequestNotificationOutboxRepository.QueryAsync) /// is a pure read-from-table projection with NO default time window — a row INSERTed /// directly into Notifications surfaces on the page exactly as a site-ingested row /// would. The actor's manual Retry / Discard handlers /// (RetryNotificationRequest / DiscardNotificationRequest) likewise act /// purely on the central row (load by id, flip Status, persist) — there is no /// site relay on this path — so a directly-seeded Parked row is genuinely /// retryable/discardable from central. This mirrors : /// each test inserts its own row at setup and best-effort deletes it at teardown, keeping /// the suite self-contained without touching infra/mssql/seed-config.sql. /// /// /// /// Rows are tagged with a unique ListName marker derived from the test name + a GUID /// so the teardown DELETE never touches rows the cluster itself produced. /// CreatedAt/SiteEnqueuedAt are pinned to "now" so the page's default /// (unconstrained) query window sees the row. /// /// internal static class NotificationDataSeeder { /// /// Connection string for the running cluster's configuration DB. /// Delegates to . /// public static string ConnectionString => PlaywrightDbConnection.ConnectionString; /// /// Inserts a single Parked row into the central Notifications table. /// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body, /// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with /// the unique so the test can filter to it and the /// teardown can delete by it. Status is fixed to Parked — the only status /// from which the page exposes Retry/Discard. Timestamps are pinned to "now" so the /// page's default unconstrained query window sees the row. /// /// GUID primary key (stored as its 36-char string form). /// Unique per-run marker stored in ListName. /// Subject text (searchable via the page's subject keyword box). /// Originating site identifier (e.g. site-a). /// Retry count to display on the row. /// Optional last-error text shown beneath the subject. /// Cancellation token. public static async Task InsertParkedNotificationAsync( Guid notificationId, string listNameMarker, string subject, string sourceSite, int retryCount = 3, string? lastError = "SMTP 451 transient failure (seeded)", CancellationToken ct = default) { // NotificationId is the varchar(64) primary key; Type/Status are stored as // varchar(32) (HasConversion()). All NOT NULL columns are supplied; // the nullable provenance columns (SourceNode, OriginExecutionId, …) are left // to default to NULL, which the page renders as an em-dash. const string sql = @" INSERT INTO [Notifications] ([NotificationId], [Type], [ListName], [Subject], [Body], [Status], [RetryCount], [LastError], [SourceSiteId], [SiteEnqueuedAt], [CreatedAt]) VALUES (@id, @type, @listName, @subject, @body, @status, @retryCount, @lastError, @sourceSite, @siteEnqueuedAt, @createdAt);"; var now = DateTimeOffset.UtcNow; await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); await using var cmd = connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@id", notificationId.ToString()); cmd.Parameters.AddWithValue("@type", "Email"); cmd.Parameters.AddWithValue("@listName", listNameMarker); cmd.Parameters.AddWithValue("@subject", subject); cmd.Parameters.AddWithValue("@body", "Seeded notification body for Playwright E2E."); cmd.Parameters.AddWithValue("@status", "Parked"); cmd.Parameters.AddWithValue("@retryCount", retryCount); cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value); cmd.Parameters.AddWithValue("@sourceSite", sourceSite); cmd.Parameters.AddWithValue("@siteEnqueuedAt", now); cmd.Parameters.AddWithValue("@createdAt", now); await cmd.ExecuteNonQueryAsync(ct); } /// /// Best-effort cleanup. Deletes every Notifications row whose ListName /// equals . Swallows all errors — the marker carries a /// per-run GUID so the rows are unique to this test run. A Retry that flips the row back /// to Pending (and a subsequent dispatch sweep) does not change the ListName, /// so the marker still matches whatever terminal state the row ends in. /// public static async Task DeleteByMarkerAsync(string listNameMarker, CancellationToken ct = default) { try { await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); await using var cmd = connection.CreateCommand(); cmd.CommandText = "DELETE FROM [Notifications] WHERE [ListName] = @listName"; cmd.Parameters.AddWithValue("@listName", listNameMarker); await cmd.ExecuteNonQueryAsync(ct); } 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. } } /// /// Probe whether the configuration DB is reachable. Tests gate their per-test setup on /// this so a downed cluster surfaces a clear message rather than an opaque /// . /// public static async Task IsAvailableAsync(CancellationToken ct = default) { try { await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); return true; } catch { return false; } } }