using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using HrynCo.NotificationService.DAL.Abstract.Repositories; using Microsoft.Extensions.DependencyInjection; namespace HrynCo.NotificationService.Web.IntegrationTests; public sealed class EmailTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture { [Fact] public async Task ServiceReadsExposeExactCatalogAndStableIdentityThroughRename() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); var service = (await context.Client.GetFromJsonAsync(context.ServiceRoot)).GetProperty("data"); Assert.Equal(context.ServiceId, service.GetProperty("id").GetGuid()); Assert.Equal("TestService", service.GetProperty("name").GetString()); Assert.Equal(new[] { "de", "en", "ru", "ru-RU", "uk" }, service.GetProperty("supportedLanguageCodes").EnumerateArray().Select(c => c.GetString())); Assert.Equal(5, service.EnumerateObject().Count()); Assert.NotEqual(Guid.Empty, service.GetProperty("revision").GetGuid()); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=testservice")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync("/api/v1/services")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}")).StatusCode); using var scope = context.App.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var model = (await repository.GetAsync(context.ServiceId))!; model.Name = "Renamed Service"; await repository.SaveAsync(model, false); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=TestService")).StatusCode); var renamed = await context.Client.GetFromJsonAsync("/api/v1/services?name=Renamed%20Service"); Assert.Equal(context.ServiceId, renamed.GetProperty("data").GetProperty("id").GetGuid()); Assert.Equal("Renamed Service", (await context.Client.GetFromJsonAsync(context.ServiceRoot)).GetProperty("data").GetProperty("name").GetString()); } [Fact] public async Task AtomicUpsertReplacesContractAndSentContent_AndRetainsOmittedTranslations() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"))).StatusCode); var original = await context.ReadAsync(); var uk = original.GetProperty("translations")[1]; Assert.True(uk.GetProperty("created").GetDateTimeOffset().Year >= 2020); var revision = original.GetProperty("revision").GetGuid(); var update = DefinitionApiTestContext.Body(revision, "en", "de"); update["variables"] = new JsonArray(new JsonObject { ["name"] = "Changed", ["required"] = false }); update["translations"]![0]!["subject"] = "Edited English"; var response = await context.UpsertAsync(update); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var returned = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); var saved = await context.ReadAsync(); Assert.Equal(saved.GetProperty("revision").GetGuid(), returned.GetProperty("revision").GetGuid()); Assert.NotEqual(revision, saved.GetProperty("revision").GetGuid()); Assert.Equal(original.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid()); Assert.Equal(3, saved.GetProperty("translations").GetArrayLength()); Assert.Equal(uk.GetRawText(), saved.GetProperty("translations")[2].GetRawText()); Assert.Equal("Edited English", saved.GetProperty("translations")[1].GetProperty("subject").GetString()); Assert.Equal(original.GetProperty("translations")[0].GetProperty("id").GetGuid(), saved.GetProperty("translations")[1].GetProperty("id").GetGuid()); var legacy = await context.Client.GetFromJsonAsync("/api/v1/email-templates/TestService/Welcome/uk"); Assert.Equal("Changed", legacy.GetProperty("data").GetProperty("variables")[0].GetProperty("name").GetString()); Assert.False(saved.GetProperty("variables")[0].GetProperty("required").GetBoolean()); } [Fact] public async Task ExplicitRemovalCanRemoveLastVersion_AndKeepDefinitionAndContract() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk")); var before = await context.ReadAsync(); var request = DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid()); request["removeLanguageCodes"] = new JsonArray("en", "uk", "de"); var response = await context.UpsertAsync(request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var returned = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); Assert.Empty(returned.GetProperty("translations").EnumerateArray()); var saved = await context.ReadAsync(); Assert.Equal(before.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid()); Assert.Empty(saved.GetProperty("translations").EnumerateArray()); Assert.Single(saved.GetProperty("variables").EnumerateArray()); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode); } [Fact] public async Task RevisionsEnforceCreateOnlyAndUpdateOnly_WithoutPartialChanges() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await context.UpsertAsync(DefinitionApiTestContext.Body(null)); var before = await context.ReadAsync(); Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"), "Missing")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl("Missing"))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.Empty))).StatusCode); Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText()); var revision = before.GetProperty("revision").GetGuid(); Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk"))).StatusCode); var otherRoot = $"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/upsert?key=Welcome"; Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsJsonAsync(otherRoot, DefinitionApiTestContext.Body(null))).StatusCode); } [Theory] [InlineData("missingVariables")] [InlineData("nullVariables")] [InlineData("missingTranslations")] [InlineData("nullTranslations")] [InlineData("nullVariable")] [InlineData("nullTranslation")] [InlineData("missingRequired")] [InlineData("nonBooleanRequired")] [InlineData("duplicateVariable")] [InlineData("blankVariable")] [InlineData("duplicateLanguage")] [InlineData("unknownLanguage")] [InlineData("blankContent")] [InlineData("missingContent")] [InlineData("duplicateRemoval")] [InlineData("unknownRemoval")] [InlineData("nullRemoval")] [InlineData("overlap")] public async Task InvalidUpdateDoesNotChangeContractTranslationsOrRevision(string scenario) { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk")); var before = await context.ReadAsync(); var request = InvalidDefinitionApiBody.Create(scenario, before.GetProperty("revision").GetGuid()); var response = await context.UpsertAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var error = await response.Content.ReadFromJsonAsync(); Assert.False(error.GetProperty("success").GetBoolean()); Assert.Equal("InvalidRequest", error.GetProperty("error").GetProperty("code").GetString()); Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText()); } [Fact] public async Task ConcurrentCreatesAndUpdatesHaveOneWinnerAndNoLostMerge() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); var created = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(null, "en")), context.UpsertAsync(DefinitionApiTestContext.Body(null, "uk"))); Assert.Equal(new[] { HttpStatusCode.Created, HttpStatusCode.Conflict }, created.Select(r => r.StatusCode).Order().ToArray()); var before = await context.ReadAsync(); var revision = before.GetProperty("revision").GetGuid(); var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "de")), context.UpsertAsync(DefinitionApiTestContext.Body(revision, "ru"))); Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray()); var after = await context.ReadAsync(); Assert.Equal(2, after.GetProperty("translations").GetArrayLength()); var retained = before.GetProperty("translations")[0]; Assert.Contains(after.GetProperty("translations").EnumerateArray(), item => item.GetProperty("id").GetGuid() == retained.GetProperty("id").GetGuid()); } [Fact] public async Task RejectedCreateAndMalformedRequestsLeaveNoDefinition() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); var definitions = context.ServiceRoot + "/email-template-definitions"; Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions)).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(definitions + "/upsert", DefinitionApiTestContext.Body(null))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions + "?key=%20")).StatusCode); var invalid = DefinitionApiTestContext.Body(null, "en", "unknown"); Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalid)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode); using var nullBody = new StringContent("null", System.Text.Encoding.UTF8, "application/json"); var response = await context.Client.PostAsync(context.DefinitionUrl(upsert: true), nullBody); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync()).GetProperty("error").GetProperty("code").GetString()); var invalidRevision = DefinitionApiTestContext.Body(null); invalidRevision["revision"] = "not-a-guid"; Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalidRevision)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode); } [Fact] public async Task ExactCaseCatalogCodesCanCoexistAndBeRemovedIndependently() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); using var scope = context.App.Services.CreateScope(); var services = scope.ServiceProvider.GetRequiredService(); var service = (await services.GetAsync(context.ServiceId))!; service.Languages = service.Languages.Append(new() { Code = "RU" }).ToList(); await services.SaveAsync(service, false); Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "RU", "ru", "ru-RU"))).StatusCode); var saved = await context.ReadAsync(); var removal = DefinitionApiTestContext.Body(saved.GetProperty("revision").GetGuid()); removal["variables"] = new JsonArray(); removal["removeLanguageCodes"] = new JsonArray("RU"); Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(removal)).StatusCode); var after = await context.ReadAsync(); Assert.Empty(after.GetProperty("variables").EnumerateArray()); Assert.Equal(new[] { "ru", "ru-RU" }, after.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString())); } [Fact] public async Task LegacyContentWriteInvalidatesLastReadRevision() { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en")); var before = await context.ReadAsync(); var body = DefinitionApiTestContext.Body(null, "en"); var legacy = (JsonObject)body["translations"]![0]!.DeepClone(); legacy["subject"] = "Legacy edit"; legacy["variables"] = body["variables"]!.DeepClone(); Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", legacy)).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid(), "uk"))).StatusCode); var after = await context.ReadAsync(); Assert.Single(after.GetProperty("translations").EnumerateArray()); Assert.Equal("Legacy edit", after.GetProperty("translations")[0].GetProperty("subject").GetString()); } [Theory] [InlineData("Welcome + % # ? Привіт")] [InlineData("Welcome%2Fexact")] [InlineData("Welcome/child")] public async Task EncodedKeysRoundTripExactly(string key) { await using var context = await DefinitionApiTestContext.CreateAsync(fixture); var created = await context.UpsertAsync(DefinitionApiTestContext.Body(null, "ru", "ru-RU"), key); Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.NotNull(created.Headers.Location); var located = await context.Client.GetFromJsonAsync(created.Headers.Location); Assert.Equal(key, located.GetProperty("data").GetProperty("key").GetString()); Assert.Equal(context.ServiceId, located.GetProperty("data").GetProperty("serviceId").GetGuid()); var read = await context.ReadAsync(key); Assert.Equal(read.GetProperty("id").GetGuid(), located.GetProperty("data").GetProperty("id").GetGuid()); Assert.Equal(key, read.GetProperty("key").GetString()); Assert.Equal(new[] { "ru", "ru-RU" }, read.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString())); Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key.ToLowerInvariant()))).StatusCode); } }