Files
hrynco-notification-service/HrynCo.NotificationService.Web.IntegrationTests/ServiceLanguageTests.cs
agrynco 81654c0d33 feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage.

Ref: IT-1601
2026-10-17 22:02:43 +03:00

257 lines
16 KiB
C#

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<TemplateDatabaseFixture>
{
[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<IEmailTemplateDefinitionRepository>().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 = "<p>Hello</p>", textBody = "Hello", variables = Array.Empty<object>() };
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<IEmailTemplateDefinitionRepository>().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<EmailTemplateWriteException>(() => scope.ServiceProvider
.GetRequiredService<INotificationServiceRepository>().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<EmailTemplateWriteException>(() => scope.ServiceProvider
.GetRequiredService<INotificationServiceRepository>().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<INotificationServiceRepository>().RemoveLanguageAsync(current.Id, language.Id, 2, current.Revision);
using var readScope = provider.CreateScope();
var definitions = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().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<INotificationServiceRepository>().SaveAsync(service, false);
using var readScope = provider.CreateScope();
var version = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().GetAsync("TestService", "First", "uk");
Assert.NotNull(version);
Assert.Null(await readScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().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, "<tr>.*?</tr>", RegexOptions.Singleline).Cast<Match>(),
row => row.Value.Contains(service.Languages[usedIndex].Id.ToString(), StringComparison.OrdinalIgnoreCase)).Value;
Assert.Contains("data-count=\"1\"", usedRow);
Assert.Contains("<td>1</td>", 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<INotificationServiceRepository>().SaveAsync(service, false);
using var readScope = provider.CreateScope();
var services = readScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
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<IEmailTemplateRepository>().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<EmailTemplateWriteException>(() => 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<object>() };
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<IEmailTemplateDefinitionRepository>().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<INotificationServiceRepository>().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<INotificationServiceRepository>().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<INotificationServiceRepository>().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<NotificationServiceInfo> ReadServiceAsync(ServiceProvider provider)
{
using var scope = provider.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().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<IEmailTemplateRepository>().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<PostgresException>(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql))).SqlState);
private static Dictionary<string, string> HiddenFields(string html)
{
var result = new Dictionary<string, string>();
foreach (Match input in Regex.Matches(html, "<input[^>]+>"))
{
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;
}
}