using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.TestHost; namespace HrynCo.NotificationService.Web.IntegrationTests; public sealed class EmailTemplateApiTests(TemplateDatabaseFixture fixture) : IClassFixture { [Fact] public async Task LegacyApi_CreatesReadsUpdatesAndDeletesLanguageVersions_WithoutContractDivergence() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); var created = await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var originalId = (await created.Content.ReadFromJsonAsync()).GetProperty("data").GetGuid(); Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await client.PostAsJsonAsync("/api/v1/email-templates", Content("de", "Other"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", Content("en", "Other"))).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/uk", Content("uk", subject: "Updated uk"))).StatusCode); var all = await client.GetFromJsonAsync("/api/v1/email-templates?serviceName=TestService&key=Welcome"); Assert.Equal(2, all.GetProperty("data").GetArrayLength()); var en = await ReadAsync(client, "en"); Assert.Equal(originalId, en.GetProperty("id").GetGuid()); Assert.Equal("Subject en", en.GetProperty("subject").GetString()); Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Welcome/uk")).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode); Assert.Contains("data-state=\"missing\"", await client.GetStringAsync("/admin/templates?serviceName=TestService")); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/v1/email-templates/TestService/Welcome/de")).StatusCode); } [Fact] public async Task ConcurrentLegacyCreates_OnlyPersistOneLanguage() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); var responses = await Task.WhenAll( client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")), client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"))); Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.Created)); Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict)); } [Fact] public async Task GroupEditor_SharedContractIsSavedForAllLanguages_AndPreservesContentIdsAndFilters() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(new() { AllowAutoRedirect = false }); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")); var originalId = (await ReadAsync(client, "uk")).GetProperty("id").GetGuid(); var fields = await EditorFieldsAsync(client); fields["VariablesJson"] = """[{"name":"Shared","required":true}]"""; fields["KeyFilter"] = "Welcome"; fields["Languages[0].Subject"] = "Edited English"; var response = await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields)); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal($"/admin/services/{fields["ServiceId"]}?key=Welcome", response.Headers.Location!.OriginalString); var uk = await ReadAsync(client, "uk"); Assert.Equal(originalId, uk.GetProperty("id").GetGuid()); Assert.Equal("Subject uk", uk.GetProperty("subject").GetString()); Assert.Equal("Shared", uk.GetProperty("variables")[0].GetProperty("name").GetString()); Assert.Equal("Edited English", (await ReadAsync(client, "en")).GetProperty("subject").GetString()); var html = await client.GetStringAsync($"/admin/services/{fields["ServiceId"]}?key=Welcome"); Assert.Single(Regex.Matches(html, "Welcome")); Assert.Contains("data-language-code=\"en\" data-state=\"present\"", html); Assert.Contains("data-language-code=\"uk\" data-state=\"present\"", html); Assert.Contains("keyFilter=Welcome", html); Assert.DoesNotContain("@filterQuery", html); } [Theory] [InlineData("{broken", "Invalid variables JSON")] [InlineData("null", "Variables must be a JSON array")] [InlineData("[{\"name\":\"Name\",\"required\":true},{\"name\":\"Name\",\"required\":false}]", "Variable names must be unique")] public async Task InvalidSharedContract_KeepsEnteredValuesAndPersistsNothing(string json, string error) { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")); var fields = await EditorFieldsAsync(client); fields["VariablesJson"] = json; fields["Languages[1].Subject"] = "Unsaved Ukrainian"; fields["ActiveLanguageIndex"] = "1"; var response = await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields)); var html = await response.Content.ReadAsStringAsync(); Assert.Contains(error, html); Assert.Contains("Unsaved Ukrainian", html); Assert.Contains("name=\"ActiveLanguageIndex\" value=\"1\"", html); Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString()); } [Fact] public async Task DuplicateLanguageIncompleteContentAndStaleSave_AreRejectedAtomically() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")); var fields = await EditorFieldsAsync(client); fields["Languages[1].LanguageCode"] = "en"; Assert.Contains("Language codes must be unique", await PostEditorAsync(client, fields)); fields["Languages[1].LanguageCode"] = "uk"; fields["Languages[1].TextBody"] = ""; Assert.Contains("TextBody field is required", await PostEditorAsync(client, fields)); fields["Languages[1].TextBody"] = "Restored text"; await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", Content("en", subject: "Concurrent change")); Assert.Contains("changed after you opened it", await PostEditorAsync(client, fields)); Assert.Equal("Concurrent change", (await ReadAsync(client, "en")).GetProperty("subject").GetString()); Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString()); } [Fact] public async Task GroupEditor_RemovesOneLanguageAndAddsAnother_WithoutChangingRemainingLanguage() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(new() { AllowAutoRedirect = false }); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")); var fields = await EditorFieldsAsync(client); fields["Languages[1].Subject"] = ""; fields["Languages[1].HtmlBody"] = ""; fields["Languages[1].TextBody"] = ""; AddLanguageFields(fields, 2, "de"); fields["Languages[2].Subject"] = "German"; Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields))).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/v1/email-templates/TestService/Welcome/uk")).StatusCode); Assert.Equal("Subject en", (await ReadAsync(client, "en")).GetProperty("subject").GetString()); Assert.Equal("German", (await ReadAsync(client, "de")).GetProperty("subject").GetString()); } [Fact] public async Task FailedSave_ShowsActionableErrorAndKeepsEnteredValues() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var failingApp = app.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => services.AddScoped())); using var client = failingApp.CreateClient(); await client.PostAsJsonAsync("/api/v1/email-templates", Content("en")); await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")); var fields = await EditorFieldsAsync(client); fields["Languages[1].Subject"] = "Unsaved after failure"; var html = await PostEditorAsync(client, fields); Assert.Contains("Could not save the template", html); Assert.Contains("Unsaved after failure", html); Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString()); } private static object Content(string language, string variable = "Name", string? subject = null) => new { serviceName = "TestService", key = "Welcome", languageCode = language, subject = subject ?? "Subject " + language, htmlBody = "

Hello {{Name}}

", textBody = "Hello {{Name}}", variables = new[] { new { name = variable, required = true } } }; private static async Task ReadAsync(HttpClient client, string language) => (await client.GetFromJsonAsync("/api/v1/email-templates/TestService/Welcome/" + language)).GetProperty("data"); private static async Task> EditorFieldsAsync(HttpClient client) { var response = await client.GetAsync("/admin/templates/TestService/Welcome/uk"); var html = response.Headers.Location is Uri location ? await client.GetStringAsync(location) : await response.Content.ReadAsStringAsync(); var fields = new Dictionary(); foreach (Match input in Regex.Matches(html, "]+>")) { var name = Regex.Match(input.Value, "name=\"([^\"]+)\""); var value = Regex.Match(input.Value, "value=\"([^\"]*)\""); if (name.Success && value.Success) fields[WebUtility.HtmlDecode(name.Groups[1].Value)] = WebUtility.HtmlDecode(value.Groups[1].Value); } foreach (var codeField in fields.Keys.Where(k => k.EndsWith(".LanguageCode")).ToList()) { var prefix = codeField[..^"LanguageCode".Length]; fields.TryAdd(prefix + "Subject", ""); fields.TryAdd(prefix + "HtmlBody", ""); fields.TryAdd(prefix + "TextBody", ""); } fields["ServiceName"] = "TestService"; fields["Key"] = "Welcome"; fields["VariablesJson"] = """[{"name":"Name","required":true}]"""; AddLanguageFields(fields, 0, "en"); AddLanguageFields(fields, 1, "uk"); return fields; } private static void AddLanguageFields(Dictionary fields, int index, string language) { fields[$"Languages[{index}].LanguageCode"] = language; fields[$"Languages[{index}].Subject"] = "Subject " + language; fields[$"Languages[{index}].HtmlBody"] = "

Hello {{Name}}

"; fields[$"Languages[{index}].TextBody"] = "Hello {{Name}}"; } private static string SaveUrl(Dictionary fields) => $"/admin/services/{fields["ServiceId"]}/templates/save"; private static async Task PostEditorAsync(HttpClient client, Dictionary fields) => await (await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); }