5c190885da
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.
192 lines
9.6 KiB
C#
192 lines
9.6 KiB
C#
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} && CreatedAt <
|
||
/// 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<string>()): 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;
|
||
}
|
||
}
|
||
}
|