Files
agrynco 8e81572386 feat(notification): add admin rest management and deletion
Add revision-safe service and template APIs, channel mapping, and validated admin controls.

Ref: IT-1601
2026-10-17 22:51:01 +03:00

138 lines
8.4 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task DefinitionDeleteRequiresAntiforgeryAndConfirmation_AndPreservesFilter()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var definition = await context.ReadAsync();
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
var html = await client.GetStringAsync(url + "?keyFilter=Welcome");
Assert.Contains("2 translation(s)", html);
Assert.Contains("Languages: en, uk", html);
var fields = TemplateEditorTestFields.Read(html);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(url, new FormUrlEncodedContent(new Dictionary<string, string> { ["ConfirmDelete"] = "true" }))).StatusCode);
var unconfirmed = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("Confirm deletion", unconfirmed);
AssertConfirmationReset(unconfirmed);
fields = TemplateEditorTestFields.Read(unconfirmed);
fields["ConfirmDelete"] = "true";
fields["Key"] = "Different";
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
Assert.Equal($"/admin/services/{context.ServiceId}?key=Welcome", deleted.Headers.Location!.OriginalString);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/admin/services/{context.ServiceId}")).StatusCode);
}
[Fact]
public async Task StaleDefinitionConfirmationRefreshesCountRevisionAndUncheckedConfirmation()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var definition = await context.ReadAsync();
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
var fields = TemplateEditorTestFields.Read(await context.Client.GetStringAsync(url));
var oldRevision = fields["Revision"];
await context.UpsertAsync(DefinitionApiTestContext.Body(definition.GetProperty("revision").GetGuid(), "en"));
fields["ConfirmDelete"] = "true";
var html = await (await context.Client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("revision changed", html);
Assert.Contains("1 translation(s)", html);
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(html)["Revision"]);
AssertConfirmationReset(html);
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
var wrongScope = $"/admin/services/{Guid.NewGuid()}/templates/{definition.GetProperty("id").GetGuid()}/delete";
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(wrongScope)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsync(wrongScope, new FormUrlEncodedContent(fields))).StatusCode);
}
[Fact]
public async Task ServiceDeleteIsBlockedByEmptyDefinition_AndRequiresFreshConfirmationAfterRename()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var url = $"/admin/services/{context.ServiceId}/delete";
var blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked);
Assert.Contains("Email channels are retained", blocked);
var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
var definition = await context.ReadAsync();
await client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid());
fields = TemplateEditorTestFields.Read(await client.GetStringAsync(url));
var oldRevision = fields["Revision"];
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Renamed Before Delete";
await services.SaveAsync(service, false);
}
fields["ConfirmDelete"] = "true";
var stale = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("revision changed", stale);
Assert.Contains("Renamed Before Delete", stale);
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(stale)["Revision"]);
AssertConfirmationReset(stale);
fields = TemplateEditorTestFields.Read(stale);
Assert.Contains("Confirm deletion", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
fields["ConfirmDelete"] = "true";
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
Assert.Equal("/admin/services", deleted.Headers.Location!.OriginalString);
}
[Fact]
public async Task EmptyStatesOfferScopedFirstCreateOrClearFilterActions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var root = $"/admin/services/{context.ServiceId}";
var templates = await context.Client.GetStringAsync(root);
Assert.Contains("No email templates for this service yet.", templates);
Assert.Contains("Create First Template", templates);
Assert.Contains(root + "/templates/create", templates);
Assert.Contains("template-key-filter", templates);
var filtered = await context.Client.GetStringAsync(root + "?key=Missing");
Assert.Contains("No templates match this key.", filtered);
Assert.Contains(">Clear filter</a>", filtered);
Assert.DoesNotContain("Create First Template", filtered);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
var services = await context.Client.GetStringAsync("/admin/services");
Assert.Contains("No services yet.", services);
Assert.Contains("Create First Service", services);
Assert.Contains("aria-hidden=\"true\"", services);
}
[Fact]
public async Task TemplateLoadFailureDoesNotPretendTheServiceHasNoTemplates()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var failing = context.App.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddScoped<IEmailTemplateDefinitionRepository, UnavailableEmailTemplateDefinitionRepository>()));
using var client = failing.CreateClient();
var html = await client.GetStringAsync($"/admin/services/{context.ServiceId}");
Assert.Contains("Could not load templates", html);
Assert.Contains("Templates are unavailable", html);
Assert.DoesNotContain("Create First Template", html);
Assert.DoesNotContain("No email templates for this service yet.", html);
}
private static void AssertConfirmationReset(string html) =>
Assert.DoesNotContain("checked", Regex.Match(html, "<input[^>]+type=\"checkbox\"[^>]*>").Value);
}