81654c0d33
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
257 lines
16 KiB
C#
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;
|
|
}
|
|
}
|