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 HrynCo.NotificationService.DAL.EF; using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; using Npgsql; namespace HrynCo.NotificationService.Web.IntegrationTests; public sealed class ServiceLanguageTests(TemplateDatabaseFixture fixture) : IClassFixture { [Fact] public async Task EmptyDefinition_CanBeCreatedAndRetainedAfterItsFinalVersionIsRemoved() { var connection = await fixture.CreateDatabaseAsync(); using var provider = Provider(connection); var definition = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Empty", Variables = [] }; using (var scope = provider.CreateScope()) await scope.ServiceProvider.GetRequiredService().SaveDefinitionAsync(definition, true); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); Assert.Contains("data-state=\"missing\"", await client.GetStringAsync("/admin/templates?serviceName=TestService&key=Empty")); var content = new { serviceName = "TestService", key = "Empty", languageCode = "en", subject = "Hello", htmlBody = "

Hello

", textBody = "Hello", variables = Array.Empty() }; Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/v1/email-templates", content)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Empty/en")).StatusCode); using var readScope = provider.CreateScope(); var saved = await readScope.ServiceProvider.GetRequiredService().GetDefinitionAsync("TestService", "Empty"); Assert.Equal(definition.Id, saved!.Id); Assert.Empty(saved.Languages); } [Fact] public async Task CatalogDeletion_BlocksOrdinaryRemoval_RequiresCurrentCount_AndRetainsDefinitions() { var connection = await fixture.CreateDatabaseAsync(); using var provider = Provider(connection); await AddTemplateAsync(provider, "First", "en"); var original = await ReadServiceAsync(provider); var language = original.Languages.Single(l => l.Code == "en"); original.Languages = original.Languages.Where(l => l.Id != language.Id).ToList(); using (var scope = provider.CreateScope()) { var error = await Assert.ThrowsAsync(() => scope.ServiceProvider .GetRequiredService().SaveAsync(original, false)); Assert.Contains("1 version(s)", error.Message); } await AddTemplateAsync(provider, "Second", "en"); using (var scope = provider.CreateScope()) { var error = await Assert.ThrowsAsync(() => scope.ServiceProvider .GetRequiredService().RemoveLanguageAsync(original.Id, language.Id, 1, original.Revision)); Assert.Contains("count changed", error.Message); } var current = await ReadServiceAsync(provider); Assert.Equal(2, current.Languages.Single(l => l.Code == "en").VersionCount); using (var scope = provider.CreateScope()) await scope.ServiceProvider.GetRequiredService().RemoveLanguageAsync(current.Id, language.Id, 2, current.Revision); using var readScope = provider.CreateScope(); var definitions = await readScope.ServiceProvider.GetRequiredService().GetDefinitionsAsync("TestService"); Assert.Equal(2, definitions.Count); Assert.All(definitions, d => Assert.Empty(d.Languages)); Assert.DoesNotContain((await ReadServiceAsync(provider)).Languages, l => l.Code == "en"); } [Fact] public async Task ServiceEditor_CanSwapLanguageCodesWithoutLosingVersionIdentity() { var connection = await fixture.CreateDatabaseAsync(); using var provider = Provider(connection); await AddTemplateAsync(provider, "First", "en"); var service = await ReadServiceAsync(provider); var en = service.Languages.Single(l => l.Code == "en"); var uk = service.Languages.Single(l => l.Code == "uk"); en.Code = "uk"; uk.Code = "en"; using (var scope = provider.CreateScope()) await scope.ServiceProvider.GetRequiredService().SaveAsync(service, false); using var readScope = provider.CreateScope(); var version = await readScope.ServiceProvider.GetRequiredService().GetAsync("TestService", "First", "uk"); Assert.NotNull(version); Assert.Null(await readScope.ServiceProvider.GetRequiredService().GetAsync("TestService", "First", "en")); } [Theory] [InlineData(true)] [InlineData(false)] public async Task FailedServiceSave_RefreshesUsedLanguageCountsAndRetainsEnteredFields(bool modelValidationFailure) { var connection = await fixture.CreateDatabaseAsync(); using var provider = Provider(connection); await AddTemplateAsync(provider, "First", "en"); var service = await ReadServiceAsync(provider); var usedIndex = service.Languages.ToList().FindIndex(l => l.Code == "en"); var unusedIndex = service.Languages.ToList().FindIndex(l => l.Code == "uk"); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); var fields = HiddenFields(await client.GetStringAsync($"/admin/services/{service.Id}/edit")); var revision = fields["Revision"]; var enteredName = modelValidationFailure ? new string('x', 101) : "Entered unsaved name"; fields["Name"] = enteredName; fields[$"Languages[{usedIndex}].Code"] = "xx"; if (!modelValidationFailure) fields[$"Languages[{unusedIndex}].Code"] = "de"; var html = await (await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); var usedRow = Assert.Single(Regex.Matches(html, ".*?", RegexOptions.Singleline).Cast(), row => row.Value.Contains(service.Languages[usedIndex].Id.ToString(), StringComparison.OrdinalIgnoreCase)).Value; Assert.Contains("data-count=\"1\"", usedRow); Assert.Contains("1", usedRow); var returnedFields = HiddenFields(html); Assert.Equal(enteredName, returnedFields["Name"]); Assert.Equal("xx", returnedFields[$"Languages[{usedIndex}].Code"]); Assert.Equal(revision, returnedFields["Revision"]); Assert.Equal("en", (await ReadServiceAsync(provider)).Languages.Single(l => l.Id == service.Languages[usedIndex].Id).Code); } [Fact] public async Task MigratedNonstandardCode_AllowsUnchangedServiceEditsAndNewLanguages() { var connection = await fixture.CreateDatabaseAsync(legacy: true); var originalVersionId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en_US", "[]"); await using (var db = TemplateDatabaseFixture.CreateContext(connection)) await db.Database.MigrateAsync(); using var provider = Provider(connection); var service = await ReadServiceAsync(provider); service.Name = "RenamedService"; service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = "uk" }]; using (var saveScope = provider.CreateScope()) await saveScope.ServiceProvider.GetRequiredService().SaveAsync(service, false); using var readScope = provider.CreateScope(); var services = readScope.ServiceProvider.GetRequiredService(); var saved = (await services.GetAsync(service.Id))!; Assert.Contains(saved.Languages, l => l.Code == "en_US"); Assert.Contains(saved.Languages, l => l.Code == "uk"); var version = await readScope.ServiceProvider.GetRequiredService().GetAsync("RenamedService", "Welcome", "en_US"); Assert.Equal(originalVersionId, version!.Id); saved.Languages.Single(l => l.Code == "en_US").Code = "en_GB"; var error = await Assert.ThrowsAsync(() => services.SaveAsync(saved, false)); Assert.Contains("new or renamed", error.Message); } [Fact] public async Task LegacyApi_RejectsUnknownServiceLanguage_WithoutCreatingDefinition() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(); var content = new { serviceName = "TestService", key = "Unknown", languageCode = "fr", subject = "Hello", htmlBody = "Hello", textBody = "Hello", variables = Array.Empty() }; var response = await client.PostAsJsonAsync("/api/v1/email-templates", content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains("catalog", await response.Content.ReadAsStringAsync()); using var scope = app.Services.CreateScope(); Assert.Null(await scope.ServiceProvider.GetRequiredService().GetDefinitionAsync("TestService", "Unknown")); } [Fact] public async Task DatabaseConstraints_RejectDuplicatesAndLanguageFromAnotherService() { var connection = await fixture.CreateDatabaseAsync(); using var provider = Provider(connection); await AddTemplateAsync(provider, "First", "en"); using (var scope = provider.CreateScope()) await scope.ServiceProvider.GetRequiredService().SaveAsync(new NotificationServiceInfo { Name = "OtherService", Languages = [new ServiceLanguageInfo { Code = "en" }] }, true); await AssertSqlStateAsync(connection, "23505", "INSERT INTO services SELECT gen_random_uuid(), name, gen_random_uuid(), created, updated FROM services WHERE name = 'TestService'"); await AssertSqlStateAsync(connection, "23505", "INSERT INTO service_languages SELECT gen_random_uuid(), service_id, code, created, updated FROM service_languages LIMIT 1"); await AssertSqlStateAsync(connection, "23505", "INSERT INTO email_template_definitions SELECT gen_random_uuid(), service_id, key, revision, variables, created, updated FROM email_template_definitions LIMIT 1"); await AssertSqlStateAsync(connection, "23505", """ INSERT INTO email_templates(id, definition_id, service_id, service_language_id, subject, html_body, text_body, created) SELECT gen_random_uuid(), definition_id, service_id, service_language_id, subject, html_body, text_body, created FROM email_templates LIMIT 1 """); await AssertSqlStateAsync(connection, "23503", """ INSERT INTO email_templates(id, definition_id, service_id, service_language_id, subject, html_body, text_body, created) SELECT gen_random_uuid(), t.definition_id, t.service_id, l.id, 'Hello', 'Hello', 'Hello', now() FROM email_templates t CROSS JOIN service_languages l JOIN services s ON s.id = l.service_id WHERE s.name = 'OtherService' LIMIT 1 """); } [Fact] public async Task ServiceForm_CreatesEditsInlineLanguages_RejectsDuplicatesAndRequiresDestructiveConfirmation() { var connection = await fixture.CreateDatabaseAsync(); await using var app = new TemplateWebApplicationFactory(connection); using var client = app.CreateClient(new() { AllowAutoRedirect = false }); var createHtml = await client.GetStringAsync("/admin/services/create"); var fields = HiddenFields(createHtml); fields["Name"] = "FormService"; fields["Languages[0].Code"] = "ru"; fields["Languages[1].Code"] = "ru-RU"; var createResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(fields)); Assert.Equal(HttpStatusCode.Redirect, createResponse.StatusCode); var createdService = (await app.Services.GetRequiredService().GetAllAsync()).Single(s => s.Name == "FormService"); var serviceUrl = $"/admin/services/{createdService.Id}"; Assert.Equal(serviceUrl, createResponse.Headers.Location!.OriginalString); var editUrl = serviceUrl + "/edit"; var editHtml = await client.GetStringAsync(editUrl); Assert.Contains("ru-RU", editHtml); var savedFields = HiddenFields(editHtml); var editResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields)); Assert.Equal(HttpStatusCode.Redirect, editResponse.StatusCode); Assert.Equal(serviceUrl, editResponse.Headers.Location!.OriginalString); savedFields = HiddenFields(await client.GetStringAsync(editUrl)); savedFields["Name"] = "FormServiceRenamed"; savedFields["Languages[0].Code"] = "ru"; savedFields["Languages[1].Code"] = "ru"; Assert.Contains("must be unique", await (await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields))).Content.ReadAsStringAsync()); savedFields["Languages[1].Code"] = "ru-RU"; var renameResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields)); Assert.Equal(HttpStatusCode.Redirect, renameResponse.StatusCode); Assert.Equal(serviceUrl, renameResponse.Headers.Location!.OriginalString); var service = (await app.Services.GetRequiredService().GetAllAsync()).Single(s => s.Name == "FormServiceRenamed"); var removalUrl = $"/admin/services/{service.Id}/languages/{service.Languages[0].Id}/delete"; var removalHtml = await client.GetStringAsync(removalUrl); var removalFields = HiddenFields(removalHtml); Assert.Contains("0 template version(s)", removalHtml); Assert.Contains("Confirm deletion", await (await client.PostAsync(removalUrl, new FormUrlEncodedContent(removalFields))).Content.ReadAsStringAsync()); removalFields["ConfirmDelete"] = "true"; Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(removalUrl, new FormUrlEncodedContent(removalFields))).StatusCode); } private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); private static async Task ReadServiceAsync(ServiceProvider provider) { using var scope = provider.CreateScope(); return (await scope.ServiceProvider.GetRequiredService().GetAllAsync()).Single(s => s.Name == "TestService"); } private static async Task AddTemplateAsync(ServiceProvider provider, string key, string language) { using var scope = provider.CreateScope(); await scope.ServiceProvider.GetRequiredService().AddAsync(new EmailTemplate { ServiceName = "TestService", Key = key, LanguageCode = language, Subject = "Hello", HtmlBody = "Hello", TextBody = "Hello" }); } private static async Task AssertSqlStateAsync(string connection, string code, string sql) => Assert.Equal(code, (await Assert.ThrowsAsync(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql))).SqlState); private static Dictionary HiddenFields(string html) { var result = 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) result[WebUtility.HtmlDecode(name.Groups[1].Value)] = WebUtility.HtmlDecode(value.Groups[1].Value); } return result; } }