feat(sms): NotificationListForm adapter-gated Type selector + per-type recipients (S7)
This commit is contained in:
+99
-15
@@ -1,10 +1,13 @@
|
|||||||
@page "/notifications/lists/create"
|
@page "/notifications/lists/create"
|
||||||
@page "/notifications/lists/{Id:int}/edit"
|
@page "/notifications/lists/{Id:int}/edit"
|
||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject INotificationRepository NotificationRepository
|
@inject INotificationRepository NotificationRepository
|
||||||
|
@inject INotificationChannelCatalog ChannelCatalog
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -25,6 +28,19 @@
|
|||||||
<input type="text" class="form-control" @bind="_name" />
|
<input type="text" class="form-control" @bind="_name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Type is create-only: a list's channel fixes which contact field its
|
||||||
|
recipients carry (email vs phone), so changing it after recipients exist
|
||||||
|
would orphan them. Disabled on edit (Id.HasValue). *@
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select" @bind="_type" disabled="@Id.HasValue">
|
||||||
|
@foreach (var channel in ChannelCatalog.SupportedChannels)
|
||||||
|
{
|
||||||
|
<option value="@channel">@ChannelLabel(channel)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_formError != null)
|
@if (_formError != null)
|
||||||
{
|
{
|
||||||
<div class="text-danger small mb-2">@_formError</div>
|
<div class="text-danger small mb-2">@_formError</div>
|
||||||
@@ -47,10 +63,23 @@
|
|||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<input type="text" class="form-control" @bind="_recipientName" />
|
<input type="text" class="form-control" @bind="_recipientName" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
@* Per-type contact field: email lists collect an email address; SMS
|
||||||
<label class="form-label">Email</label>
|
lists collect an E.164 phone number. *@
|
||||||
<input type="email" class="form-control" @bind="_recipientEmail" />
|
@if (_type == NotificationType.Sms)
|
||||||
</div>
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="tel" class="form-control" placeholder="+15551234567"
|
||||||
|
@bind="_recipientPhone" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" @bind="_recipientEmail" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (_recipientFormError != null)
|
@if (_recipientFormError != null)
|
||||||
{
|
{
|
||||||
<div class="text-danger small mt-2">@_recipientFormError</div>
|
<div class="text-danger small mt-2">@_recipientFormError</div>
|
||||||
@@ -65,7 +94,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>@(_type == NotificationType.Sms ? "Phone" : "Email")</th>
|
||||||
<th style="width:80px;">Actions</th>
|
<th style="width:80px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -80,7 +109,7 @@
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@r.Name</td>
|
<td>@r.Name</td>
|
||||||
<td>@r.EmailAddress</td>
|
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -98,17 +127,29 @@
|
|||||||
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string _name = "";
|
private string _name = "";
|
||||||
|
|
||||||
|
// Delivery channel for the list. Selectable only on create (an empty Id); on edit it
|
||||||
|
// reflects the persisted list's Type and the selector is disabled. Defaults to the
|
||||||
|
// first supported channel so the create form never starts on an undeliverable type.
|
||||||
|
private NotificationType _type = NotificationType.Email;
|
||||||
private string? _formError;
|
private string? _formError;
|
||||||
|
|
||||||
private NotificationList? _existing;
|
private NotificationList? _existing;
|
||||||
|
|
||||||
// Recipients
|
// Recipients
|
||||||
private List<NotificationRecipient> _recipients = new();
|
private List<NotificationRecipient> _recipients = new();
|
||||||
private string _recipientName = "", _recipientEmail = "";
|
private string _recipientName = "", _recipientEmail = "", _recipientPhone = "";
|
||||||
private string? _recipientFormError;
|
private string? _recipientFormError;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
// Default the create-form selection to the first supported channel (catalog is
|
||||||
|
// never empty on a correctly configured central node).
|
||||||
|
if (!Id.HasValue && ChannelCatalog.SupportedChannels.Count > 0)
|
||||||
|
{
|
||||||
|
_type = ChannelCatalog.SupportedChannels[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (Id.HasValue)
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -117,6 +158,7 @@
|
|||||||
if (_existing != null)
|
if (_existing != null)
|
||||||
{
|
{
|
||||||
_name = _existing.Name;
|
_name = _existing.Name;
|
||||||
|
_type = _existing.Type;
|
||||||
}
|
}
|
||||||
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
|
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
|
||||||
}
|
}
|
||||||
@@ -125,6 +167,12 @@
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ChannelLabel(NotificationType type) => type switch
|
||||||
|
{
|
||||||
|
NotificationType.Sms => "SMS",
|
||||||
|
_ => "Email"
|
||||||
|
};
|
||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
_formError = null;
|
_formError = null;
|
||||||
@@ -143,7 +191,10 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var nl = new NotificationList(_name.Trim());
|
// Type is set at create time and never changes thereafter (the selector
|
||||||
|
// is disabled on edit) so a list's recipients always carry the matching
|
||||||
|
// contact field.
|
||||||
|
var nl = new NotificationList(_name.Trim()) { Type = _type };
|
||||||
await NotificationRepository.AddNotificationListAsync(nl);
|
await NotificationRepository.AddNotificationListAsync(nl);
|
||||||
}
|
}
|
||||||
await NotificationRepository.SaveChangesAsync();
|
await NotificationRepository.SaveChangesAsync();
|
||||||
@@ -155,26 +206,59 @@
|
|||||||
private async Task SaveRecipient()
|
private async Task SaveRecipient()
|
||||||
{
|
{
|
||||||
_recipientFormError = null;
|
_recipientFormError = null;
|
||||||
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail))
|
if (string.IsNullOrWhiteSpace(_recipientName))
|
||||||
{
|
{
|
||||||
_recipientFormError = "Name and email required.";
|
_recipientFormError = "Name required.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationRecipient r;
|
||||||
|
if (_type == NotificationType.Sms)
|
||||||
|
{
|
||||||
|
var phone = _recipientPhone.Trim();
|
||||||
|
if (!IsValidPhone(phone))
|
||||||
|
{
|
||||||
|
_recipientFormError = "A valid phone number is required (digits, optional leading +).";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r = NotificationRecipient.ForSms(_recipientName.Trim(), phone);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var email = _recipientEmail.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
|
||||||
|
{
|
||||||
|
_recipientFormError = "A valid email address is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r = NotificationRecipient.ForEmail(_recipientName.Trim(), email);
|
||||||
|
}
|
||||||
|
r.NotificationListId = Id!.Value;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim())
|
|
||||||
{
|
|
||||||
NotificationListId = Id!.Value
|
|
||||||
};
|
|
||||||
await NotificationRepository.AddRecipientAsync(r);
|
await NotificationRepository.AddRecipientAsync(r);
|
||||||
await NotificationRepository.SaveChangesAsync();
|
await NotificationRepository.SaveChangesAsync();
|
||||||
_recipientName = _recipientEmail = string.Empty;
|
_recipientName = _recipientEmail = _recipientPhone = string.Empty;
|
||||||
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
|
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _recipientFormError = ex.Message; }
|
catch (Exception ex) { _recipientFormError = ex.Message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E.164-ish: non-empty, an optional leading '+' then digits only. Kept permissive
|
||||||
|
// (matches the SMS adapter's recipient handling) rather than enforcing a strict
|
||||||
|
// regional format or length.
|
||||||
|
private static bool IsValidPhone(string phone)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(phone))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var digits = phone.StartsWith('+') ? phone[1..] : phone;
|
||||||
|
return digits.Length > 0 && digits.All(char.IsDigit);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteRecipient(NotificationRecipient r)
|
private async Task DeleteRecipient(NotificationRecipient r)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ public static class ServiceCollectionExtensions
|
|||||||
// + base-changed staleness banner; never mutates rows, not on the deploy path.
|
// + base-changed staleness banner; never mutates rows, not on the deploy path.
|
||||||
services.AddScoped<ITemplateInheritanceQueryService, TemplateInheritanceQueryService>();
|
services.AddScoped<ITemplateInheritanceQueryService, TemplateInheritanceQueryService>();
|
||||||
|
|
||||||
|
// SMS Notifications (S7): the NotificationListForm Type selector offers only the
|
||||||
|
// notification channels that actually have a registered delivery adapter. The
|
||||||
|
// catalog projects the registered INotificationDeliveryAdapter set (Email + SMS,
|
||||||
|
// registered scoped by AddNotificationOutbox into this same container) to its
|
||||||
|
// distinct NotificationType set — no hardcoded {Email, Sms}. Scoped because the
|
||||||
|
// adapters it enumerates are scoped (they hold a scoped INotificationRepository).
|
||||||
|
services.AddScoped<INotificationChannelCatalog, NotificationChannelCatalog>();
|
||||||
|
|
||||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the set of notification delivery channels that actually have a registered
|
||||||
|
/// delivery adapter, so the Central UI never offers a notification-list Type that the
|
||||||
|
/// central node cannot deliver. The list is derived from the registered
|
||||||
|
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery.INotificationDeliveryAdapter"/>
|
||||||
|
/// set (each adapter handles a single <see cref="NotificationType"/>) rather than from
|
||||||
|
/// the <see cref="NotificationType"/> enum, so adding/removing an adapter changes the UI
|
||||||
|
/// options automatically.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationChannelCatalog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The distinct notification channels that have a registered delivery adapter,
|
||||||
|
/// in a stable order. Never empty in a correctly configured central node.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<NotificationType> SupportedChannels { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="INotificationChannelCatalog"/> that derives the supported channels
|
||||||
|
/// from the registered <see cref="INotificationDeliveryAdapter"/> set. The central Host
|
||||||
|
/// registers the Email + SMS adapters (see <c>AddNotificationOutbox</c>) into the same DI
|
||||||
|
/// container that hosts the Central UI, so projecting each adapter's
|
||||||
|
/// <see cref="INotificationDeliveryAdapter.Type"/> yields exactly the channels the central
|
||||||
|
/// node can actually deliver — no hardcoded <c>{Email, Sms}</c> list to drift.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotificationChannelCatalog : INotificationChannelCatalog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the catalog from the registered delivery adapters, snapshotting the
|
||||||
|
/// distinct channel set once (the adapter registrations are fixed for the process
|
||||||
|
/// lifetime). Ordering follows the <see cref="NotificationType"/> enum so the UI Type
|
||||||
|
/// selector renders in a stable order regardless of adapter registration order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="adapters">The registered delivery adapters (one per channel).</param>
|
||||||
|
public NotificationChannelCatalog(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(adapters);
|
||||||
|
|
||||||
|
SupportedChannels = adapters
|
||||||
|
.Select(a => a.Type)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(t => (int)t)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<NotificationType> SupportedChannels { get; }
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||||
|
<!-- SMS Notifications (S7): the NotificationListForm Type selector derives its options
|
||||||
|
from the registered INotificationDeliveryAdapter set (via INotificationChannelCatalog)
|
||||||
|
so it reflects the channels the central node can actually deliver — never a hardcoded
|
||||||
|
{Email, Sms}. The adapters live in NotificationOutbox; no NuGet packages added. -->
|
||||||
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
using NotificationListForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationListForm;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for the adapter-gated Type selector + per-type recipient input on the
|
||||||
|
/// Notification List create/edit form (SMS Notifications, S7).
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationListFormTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly INotificationRepository _repo = Substitute.For<INotificationRepository>();
|
||||||
|
|
||||||
|
public NotificationListFormTests()
|
||||||
|
{
|
||||||
|
Services.AddSingleton(_repo);
|
||||||
|
// The Type selector derives its options from INotificationChannelCatalog
|
||||||
|
// (S7). Register a substitute exposing both channels so the selector renders
|
||||||
|
// Email + SMS without pulling the real adapter graph into the test.
|
||||||
|
var catalog = Substitute.For<INotificationChannelCatalog>();
|
||||||
|
catalog.SupportedChannels.Returns(
|
||||||
|
new[] { NotificationType.Email, NotificationType.Sms });
|
||||||
|
Services.AddSingleton(catalog);
|
||||||
|
AddTestAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddTestAuth()
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
|
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TypeSelector_RendersRegisteredChannels()
|
||||||
|
{
|
||||||
|
var cut = Render<NotificationListForm>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var optionTexts = cut.FindAll("select.form-select option")
|
||||||
|
.Select(o => o.TextContent.Trim())
|
||||||
|
.ToList();
|
||||||
|
Assert.Contains("Email", optionTexts);
|
||||||
|
Assert.Contains("SMS", optionTexts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TypeSelector_IsEnabled_OnCreate()
|
||||||
|
{
|
||||||
|
var cut = Render<NotificationListForm>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var select = cut.Find("select.form-select");
|
||||||
|
Assert.False(select.HasAttribute("disabled"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectingEmail_ShowsEmailInput_NotPhone()
|
||||||
|
{
|
||||||
|
// Edit mode renders the recipient form (recipients only show once a list exists).
|
||||||
|
_repo.GetNotificationListByIdAsync(1)
|
||||||
|
.Returns(Task.FromResult<NotificationList?>(new NotificationList("Ops") { Id = 1, Type = NotificationType.Email }));
|
||||||
|
_repo.GetRecipientsByListIdAsync(1)
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(new List<NotificationRecipient>()));
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>(p => p.Add(c => c.Id, 1));
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.NotNull(cut.Find("input[type=email]"));
|
||||||
|
Assert.Empty(cut.FindAll("input[type=tel]"));
|
||||||
|
// The recipients table column header reflects the list type.
|
||||||
|
Assert.Contains("Email", cut.FindAll("th").Select(t => t.TextContent.Trim()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectingSms_ShowsPhoneInput_NotEmail()
|
||||||
|
{
|
||||||
|
_repo.GetNotificationListByIdAsync(2)
|
||||||
|
.Returns(Task.FromResult<NotificationList?>(new NotificationList("On-Call") { Id = 2, Type = NotificationType.Sms }));
|
||||||
|
_repo.GetRecipientsByListIdAsync(2)
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(new List<NotificationRecipient>()));
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>(p => p.Add(c => c.Id, 2));
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.NotNull(cut.Find("input[type=tel]"));
|
||||||
|
Assert.Empty(cut.FindAll("input[type=email]"));
|
||||||
|
Assert.Contains("Phone", cut.FindAll("th").Select(t => t.TextContent.Trim()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TypeSelector_IsDisabled_OnEdit()
|
||||||
|
{
|
||||||
|
_repo.GetNotificationListByIdAsync(3)
|
||||||
|
.Returns(Task.FromResult<NotificationList?>(new NotificationList("Ops") { Id = 3, Type = NotificationType.Email }));
|
||||||
|
_repo.GetRecipientsByListIdAsync(3)
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(new List<NotificationRecipient>()));
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>(p => p.Add(c => c.Id, 3));
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var select = cut.Find("select.form-select");
|
||||||
|
Assert.True(select.HasAttribute("disabled"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatingSmsList_PersistsTypeSms()
|
||||||
|
{
|
||||||
|
NotificationList? captured = null;
|
||||||
|
_repo.AddNotificationListAsync(Arg.Do<NotificationList>(l => captured = l))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>();
|
||||||
|
|
||||||
|
cut.Find("input[type=text]").Change("On-Call");
|
||||||
|
// Switch the Type selector to SMS, then save.
|
||||||
|
cut.Find("select.form-select").Change(NotificationType.Sms.ToString());
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(NotificationType.Sms, captured!.Type);
|
||||||
|
Assert.Equal("On-Call", captured.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddingSmsRecipient_CreatesForSmsRecipient_WithPhoneSet()
|
||||||
|
{
|
||||||
|
NotificationRecipient? captured = null;
|
||||||
|
_repo.GetNotificationListByIdAsync(4)
|
||||||
|
.Returns(Task.FromResult<NotificationList?>(new NotificationList("On-Call") { Id = 4, Type = NotificationType.Sms }));
|
||||||
|
_repo.GetRecipientsByListIdAsync(4)
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(new List<NotificationRecipient>()));
|
||||||
|
_repo.AddRecipientAsync(Arg.Do<NotificationRecipient>(r => captured = r))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>(p => p.Add(c => c.Id, 4));
|
||||||
|
|
||||||
|
cut.WaitForState(() => cut.FindAll("input[type=tel]").Count > 0);
|
||||||
|
|
||||||
|
// The first text input is the list Name; the recipient Name input is the last.
|
||||||
|
cut.FindAll("input[type=text]").Last().Change("Jane");
|
||||||
|
cut.Find("input[type=tel]").Change("+15551234567");
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Add").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal("+15551234567", captured!.PhoneNumber);
|
||||||
|
Assert.Null(captured.EmailAddress);
|
||||||
|
Assert.Equal(4, captured.NotificationListId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddingSmsRecipient_RejectsInvalidPhone()
|
||||||
|
{
|
||||||
|
_repo.GetNotificationListByIdAsync(5)
|
||||||
|
.Returns(Task.FromResult<NotificationList?>(new NotificationList("On-Call") { Id = 5, Type = NotificationType.Sms }));
|
||||||
|
_repo.GetRecipientsByListIdAsync(5)
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(new List<NotificationRecipient>()));
|
||||||
|
|
||||||
|
var cut = Render<NotificationListForm>(p => p.Add(c => c.Id, 5));
|
||||||
|
|
||||||
|
cut.WaitForState(() => cut.FindAll("input[type=tel]").Count > 0);
|
||||||
|
|
||||||
|
// The first text input is the list Name; the recipient Name input is the last.
|
||||||
|
cut.FindAll("input[type=text]").Last().Change("Jane");
|
||||||
|
cut.Find("input[type=tel]").Change("not-a-number");
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Add").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Contains("valid phone number", cut.Markup);
|
||||||
|
_repo.DidNotReceive().AddRecipientAsync(Arg.Any<NotificationRecipient>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user