Files
Joseph Doherty 5c190885da test(playwright): generalize NotificationDataSeeder for status/created-at (Wave 4 prep)
Add InsertNotificationAsync with explicit status/createdAt parameters so tests
can seed back-dated Retrying rows that satisfy the IsStuck derived property
(Status ∈ {Pending,Retrying} && CreatedAt < now − 10 min). Refactor
InsertParkedNotificationAsync to delegate to it, preserving its exact public
signature and producing identical SQL for existing callers.
2026-06-07 03:31:28 -04:00

192 lines
9.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
/// <summary>
/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests
/// (Notification Outbox #21).
///
/// <para>
/// The Notification Report page reads the central <c>Notifications</c> table through the
/// <c>NotificationOutboxActor</c> singleton. Its query path
/// (<c>NotificationOutboxQueryRequest</c> → <c>NotificationOutboxRepository.QueryAsync</c>)
/// is a pure read-from-table projection with NO default time window — a row INSERTed
/// directly into <c>Notifications</c> surfaces on the page exactly as a site-ingested row
/// would. The actor's manual Retry / Discard handlers
/// (<c>RetryNotificationRequest</c> / <c>DiscardNotificationRequest</c>) likewise act
/// purely on the central row (load by id, flip <c>Status</c>, persist) — there is no
/// site relay on this path — so a directly-seeded <c>Parked</c> row is genuinely
/// retryable/discardable from central. This mirrors <see cref="SiteCalls.SiteCallDataSeeder"/>:
/// each test inserts its own row at setup and best-effort deletes it at teardown, keeping
/// the suite self-contained without touching <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Rows are tagged with a unique <c>ListName</c> marker derived from the test name + a GUID
/// so the teardown <c>DELETE</c> never touches rows the cluster itself produced.
/// <c>CreatedAt</c>/<c>SiteEnqueuedAt</c> are pinned to "now" so the page's default
/// (unconstrained) query window sees the row.
/// </para>
/// </summary>
internal static class NotificationDataSeeder
{
/// <summary>
/// Connection string for the running cluster's configuration DB.
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
/// </summary>
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
/// <summary>
/// Inserts one notification row with explicit <paramref name="status"/> and
/// <paramref name="createdAt"/> (for stuck/age edge-case tests).
///
/// <para>
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row
/// with the unique <paramref name="listNameMarker"/> so the test can filter to it and
/// the teardown can delete by it. <c>SiteEnqueuedAt</c> is set equal to
/// <paramref name="createdAt"/>. All nullable provenance columns (SourceNode,
/// OriginExecutionId, …) are left to default to NULL, which the page renders as an
/// em-dash.
/// </para>
///
/// <para>
/// To seed a genuinely stuck row set <paramref name="status"/> to <c>Retrying</c> (or
/// <c>Pending</c>) and <paramref name="createdAt"/> to more than 10 minutes in the past —
/// <c>IsStuck</c> is derived as <c>Status ∈ {Pending, Retrying} &amp;&amp; CreatedAt &lt;
/// now 10 min</c>.
/// </para>
/// </summary>
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
/// <param name="status">
/// Status value stored as varchar (HasConversion&lt;string&gt;()): e.g. <c>Pending</c>,
/// <c>Retrying</c>, <c>Parked</c>, <c>Delivered</c>, <c>Discarded</c>.
/// </param>
/// <param name="createdAt">
/// Timestamp written to both <c>CreatedAt</c> and <c>SiteEnqueuedAt</c>. Pass a
/// back-dated value (e.g. <c>DateTimeOffset.UtcNow.AddMinutes(-15)</c>) to produce a
/// stuck row.
/// </param>
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
/// <param name="retryCount">Retry count to display on the row.</param>
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
/// <param name="ct">Cancellation token.</param>
public static async Task InsertNotificationAsync(
Guid notificationId,
string listNameMarker,
string subject,
string status,
DateTimeOffset createdAt,
string sourceSite = "site-a",
int retryCount = 0,
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<string>()). 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);";
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", status);
cmd.Parameters.AddWithValue("@retryCount", retryCount);
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
cmd.Parameters.AddWithValue("@siteEnqueuedAt", createdAt);
cmd.Parameters.AddWithValue("@createdAt", createdAt);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>
/// Inserts a single <c>Parked</c> row into the central <c>Notifications</c> table.
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
/// the unique <paramref name="listNameMarker"/> so the test can filter to it and the
/// teardown can delete by it. <c>Status</c> is fixed to <c>Parked</c> — 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.
/// </summary>
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
/// <param name="retryCount">Retry count to display on the row.</param>
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
/// <param name="ct">Cancellation token.</param>
public static Task InsertParkedNotificationAsync(
Guid notificationId,
string listNameMarker,
string subject,
string sourceSite,
int retryCount = 3,
string? lastError = "SMTP 451 transient failure (seeded)",
CancellationToken ct = default)
=> InsertNotificationAsync(
notificationId, listNameMarker, subject,
status: "Parked", createdAt: DateTimeOffset.UtcNow,
sourceSite: sourceSite, retryCount: retryCount, lastError: lastError, ct: ct);
/// <summary>
/// Best-effort cleanup. Deletes every <c>Notifications</c> row whose <c>ListName</c>
/// equals <paramref name="listNameMarker"/>. 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 <c>Pending</c> (and a subsequent dispatch sweep) does not change the <c>ListName</c>,
/// so the marker still matches whatever terminal state the row ends in.
/// </summary>
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.
}
}
/// <summary>
/// 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
/// <see cref="SqlException"/>.
/// </summary>
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
return true;
}
catch
{
return false;
}
}
}