Files
hrynco-notification-service/HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateApiTests.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

213 lines
13 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 Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.TestHost;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[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<JsonElement>()).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<JsonElement>("/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, "<td>Welcome</td>"));
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<IEmailTemplateDefinitionRepository, FailingEmailTemplateDefinitionRepository>()));
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 = "<p>Hello {{Name}}</p>", textBody = "Hello {{Name}}",
variables = new[] { new { name = variable, required = true } }
};
private static async Task<JsonElement> ReadAsync(HttpClient client, string language) =>
(await client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates/TestService/Welcome/" + language)).GetProperty("data");
private static async Task<Dictionary<string, string>> 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<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)
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<string, string> fields, int index, string language)
{
fields[$"Languages[{index}].LanguageCode"] = language;
fields[$"Languages[{index}].Subject"] = "Subject " + language;
fields[$"Languages[{index}].HtmlBody"] = "<p>Hello {{Name}}</p>";
fields[$"Languages[{index}].TextBody"] = "Hello {{Name}}";
}
private static string SaveUrl(Dictionary<string, string> fields) => $"/admin/services/{fields["ServiceId"]}/templates/save";
private static async Task<string> PostEditorAsync(HttpClient client, Dictionary<string, string> fields) =>
await (await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
}