Files
hrynco-notification-service/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs
T
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

154 lines
10 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ListsExposeRevisions_AndDefinitionsAreServiceScopedIncludingEmptyDefinitions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null), "Empty");
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Populated");
using var scope = context.App.Services.CreateScope();
var other = new NotificationServiceInfo { Name = "OtherService" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var services = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all")).GetProperty("data");
Assert.Equal(2, services.GetArrayLength());
Assert.All(services.EnumerateArray(), s => Assert.NotEqual(Guid.Empty, s.GetProperty("revision").GetGuid()));
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
Assert.Equal(new[] { "Empty", "Populated" }, definitions.EnumerateArray().Select(d => d.GetProperty("key").GetString()));
Assert.Empty(definitions[0].GetProperty("translations").EnumerateArray());
Assert.Single(definitions[1].GetProperty("translations").EnumerateArray());
Assert.All(definitions.EnumerateArray(), d => Assert.Equal(context.ServiceId, d.GetProperty("serviceId").GetGuid()));
var otherDefinitions = await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/services/{other.Id}/email-template-definitions/all");
Assert.Empty(otherDefinitions.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/all")).StatusCode);
}
[Fact]
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await SeedChannelAsync(context);
const string key = "Delete / %2F Привіт";
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"), key);
var definition = await context.ReadAsync(key);
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
var deleted = await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
var result = await deleted.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(result.GetProperty("success").GetBoolean());
Assert.Equal(JsonValueKind.Object, result.GetProperty("data").ValueKind);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key))).StatusCode);
var afterDefinitionDeletion = await ReadServiceAsync(context);
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
Assert.Empty(all.GetProperty("data").EnumerateArray());
using var scope = context.App.Services.CreateScope();
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
}
[Fact]
public async Task DeleteIsRevisionGuarded_AndEvenEmptyDefinitionsBlockServiceRemoval()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var definition = await context.ReadAsync();
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + Guid.NewGuid())).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var edit = (await repository.GetAsync(context.ServiceId))!;
edit.Name = "RenamedService";
await repository.SaveAsync(edit, false);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal("RenamedService", (await ReadServiceAsync(context)).GetProperty("name").GetString());
Assert.Empty((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
}
[Theory]
[InlineData("")]
[InlineData("?revision=not-a-guid")]
[InlineData("?revision=00000000-0000-0000-0000-000000000000")]
public async Task InvalidServiceDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var before = await ReadServiceAsync(context);
var response = await context.Client.DeleteAsync(context.ServiceRoot + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await ReadServiceAsync(context)).GetRawText());
}
[Theory]
[InlineData("?key=Welcome")]
[InlineData("?key=Welcome&revision=not-a-guid")]
[InlineData("?key=Welcome&revision=00000000-0000-0000-0000-000000000000")]
[InlineData("?revision=11111111-1111-1111-1111-111111111111")]
public async Task InvalidDefinitionDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var response = await context.Client.DeleteAsync(context.ServiceRoot + "/email-template-definitions" + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentDefinitionUpdateAndDeleteHaveOneWinner()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var revision = (await context.ReadAsync()).GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk")), context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + revision));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
}
[Fact]
public async Task ConcurrentServiceDeleteAndDefinitionCreationCannotLeaveOrphans()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = await ReadServiceAsync(context);
var deletion = context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
var creation = context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var responses = await Task.WhenAll(deletion, creation);
var deleted = responses[0].StatusCode;
var created = responses[1].StatusCode;
Assert.True(deleted == HttpStatusCode.OK && created == HttpStatusCode.NotFound
|| deleted == HttpStatusCode.Conflict && created == HttpStatusCode.Created);
}
private static async Task<JsonElement> ReadServiceAsync(DefinitionApiTestContext context) =>
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
private static async Task SeedChannelAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
}
}