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