Files
hrynco-notification-service/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateDefinitionApiTests.cs
T
agrynco 517b38791b feat(api): manage localized push template definitions
Add revision-protected reads, atomic upsert and deletion with structured actions. Preserve omitted translations and fix insertion of new push languages.

Ref: IT-1627
2026-10-31 10:03:47 +02:00

126 lines
7.4 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
private static string Url(DefinitionApiTestContext c, string suffix = "", string key = "Welcome") =>
c.ServiceRoot + "/push-template-definitions" + suffix + "?key=" + Uri.EscapeDataString(key);
private static JsonObject Body(Guid? revision, params string[] languages) => new()
{
["revision"] = revision?.ToString(),
["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }),
["translations"] = new JsonArray(languages.Select(code => (JsonNode)new JsonObject
{
["languageCode"] = code, ["title"] = "Title " + code, ["body"] = "Hello {{Name}}",
["actions"] = new JsonObject { ["defaultAction"] = "contacts", ["buttons"] = new JsonArray(
new JsonObject { ["action"] = "contacts", ["label"] = "Go " + code }) }
}).ToArray())
};
private static async Task<JsonElement> Read(DefinitionApiTestContext c) =>
(await c.Client.GetFromJsonAsync<JsonElement>(Url(c))).GetProperty("data");
[Fact]
public async Task CreatePatchReadAndDeletePreserveEmailAndOmittedLanguages()
{
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
await c.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var email = await c.ReadAsync();
var create = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk"));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
Assert.NotNull(create.Headers.Location);
var original = await Read(c);
var revision = original.GetProperty("revision").GetGuid();
var update = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, "de"));
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
var saved = await Read(c);
Assert.Equal(3, saved.GetProperty("translations").GetArrayLength());
Assert.Equal(original.GetProperty("translations")[1].GetRawText(), saved.GetProperty("translations")[2].GetRawText());
Assert.Equal("contacts", saved.GetProperty("translations")[0].GetProperty("actions").GetProperty("defaultAction").GetString());
var all = await c.Client.GetFromJsonAsync<JsonElement>(c.ServiceRoot + "/push-template-definitions/all");
Assert.Single(all.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.Conflict, (await c.Client.DeleteAsync(Url(c) + "&revision=" + revision)).StatusCode);
var remove = Body(saved.GetProperty("revision").GetGuid());
remove["removeLanguageCodes"] = new JsonArray("de", "en", "uk");
Assert.Equal(HttpStatusCode.OK, (await c.Client.PostAsJsonAsync(Url(c, "/upsert"), remove)).StatusCode);
saved = await Read(c);
Assert.Empty(saved.GetProperty("translations").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await c.Client.DeleteAsync(Url(c) + "&revision=" + saved.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync(Url(c))).StatusCode);
Assert.Equal(email.GetRawText(), (await c.ReadAsync()).GetRawText());
}
[Theory]
[InlineData("nullVariables")]
[InlineData("missingTranslations")]
[InlineData("nullTranslation")]
[InlineData("duplicateLanguage")]
[InlineData("unknownLanguage")]
[InlineData("nullActions")]
[InlineData("unknownActionField")]
[InlineData("blankLabel")]
[InlineData("unknownPlaceholder")]
[InlineData("invalidRetainedTranslation")]
[InlineData("overlap")]
[InlineData("unknownRemoval")]
[InlineData("emptyRevision")]
public async Task InvalidPatchPreservesEntireDefinition(string scenario)
{
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk"));
var before = await Read(c);
var body = Body(before.GetProperty("revision").GetGuid(), "en");
ApplyInvalidCase(body, scenario);
var result = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), body);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
Assert.False((await result.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("success").GetBoolean());
Assert.Equal(before.GetRawText(), (await Read(c)).GetRawText());
}
private static void ApplyInvalidCase(JsonObject body, string scenario)
{
var translation = body["translations"]![0]!;
var cases = new Dictionary<string, Action>
{
["nullVariables"] = () => { body["variables"] = null; },
["missingTranslations"] = () => { body.Remove("translations"); },
["nullTranslation"] = () => { body["translations"] = new JsonArray((JsonNode?)null); },
["duplicateLanguage"] = () => { body["translations"]!.AsArray().Add(translation.DeepClone()); },
["unknownLanguage"] = () => { translation["languageCode"] = "fr"; },
["nullActions"] = () => { translation["actions"] = null; },
["unknownActionField"] = () => { translation["actions"]!["url"] = "ignored"; },
["blankLabel"] = () => { translation["actions"]!["buttons"]![0]!["label"] = ""; },
["unknownPlaceholder"] = () => { translation["body"] = "{{Unknown}}"; },
["invalidRetainedTranslation"] = () => { body["variables"] = new JsonArray(); body["translations"] = new JsonArray(); },
["overlap"] = () => { body["removeLanguageCodes"] = new JsonArray("en"); },
["unknownRemoval"] = () => { body["removeLanguageCodes"] = new JsonArray("fr"); },
["emptyRevision"] = () => { body["revision"] = Guid.Empty.ToString(); },
};
cases[scenario]();
}
[Fact]
public async Task ConcurrentUpdatesAndCreatesRejectStaleRevisions()
{
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
var creates = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en"))));
Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Created);
Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Conflict);
var revision = (await Read(c)).GetProperty("revision").GetGuid();
var updates = await Task.WhenAll(new[] { "de", "uk" }.Select(l => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, l))));
Assert.Single(updates, r => r.StatusCode == HttpStatusCode.OK);
Assert.Single(updates, r => r.StatusCode == HttpStatusCode.Conflict);
Assert.Equal(2, (await Read(c)).GetProperty("translations").GetArrayLength());
Assert.Equal(HttpStatusCode.Conflict, (await c.Client.PostAsJsonAsync(Url(c, "/upsert", "Missing"), Body(Guid.NewGuid(), "en"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await c.Client.DeleteAsync(Url(c))).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/all")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.PostAsJsonAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/upsert?key=X", Body(null, "en"))).StatusCode);
}
}