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 { [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().SaveAsync(other, true); var services = (await context.Client.GetFromJsonAsync("/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(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($"/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(); 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("/api/v1/services/all"); Assert.Empty(all.GetProperty("data").EnumerateArray()); using var scope = context.App.Services.CreateScope(); Assert.Single(await scope.ServiceProvider.GetRequiredService().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(); 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()).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()).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 ReadServiceAsync(DefinitionApiTestContext context) => (await context.Client.GetFromJsonAsync(context.ServiceRoot)).GetProperty("data"); private static async Task SeedChannelAsync(DefinitionApiTestContext context) { using var scope = context.App.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService().AddAsync(new EmailChannel { ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp, Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" } }); await scope.ServiceProvider.GetRequiredService().SaveChangesAsync(); } }