feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user