Files
hrynco-notification-service/HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateDefinitionApiTests.cs
agrynco 8e81572386 feat(notification): add admin rest management and deletion
Add revision-safe service and template APIs, channel mapping, and validated admin controls.

Ref: IT-1601
2026-10-17 22:51:01 +03:00

229 lines
15 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ServiceReadsExposeExactCatalogAndStableIdentityThroughRename()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
Assert.Equal(context.ServiceId, service.GetProperty("id").GetGuid());
Assert.Equal("TestService", service.GetProperty("name").GetString());
Assert.Equal(new[] { "de", "en", "ru", "ru-RU", "uk" }, service.GetProperty("supportedLanguageCodes").EnumerateArray().Select(c => c.GetString()));
Assert.Equal(5, service.EnumerateObject().Count());
Assert.NotEqual(Guid.Empty, service.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=testservice")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync("/api/v1/services")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}")).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var model = (await repository.GetAsync(context.ServiceId))!;
model.Name = "Renamed Service";
await repository.SaveAsync(model, false);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=TestService")).StatusCode);
var renamed = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services?name=Renamed%20Service");
Assert.Equal(context.ServiceId, renamed.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal("Renamed Service", (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data").GetProperty("name").GetString());
}
[Fact]
public async Task AtomicUpsertReplacesContractAndSentContent_AndRetainsOmittedTranslations()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"))).StatusCode);
var original = await context.ReadAsync();
var uk = original.GetProperty("translations")[1];
Assert.True(uk.GetProperty("created").GetDateTimeOffset().Year >= 2020);
var revision = original.GetProperty("revision").GetGuid();
var update = DefinitionApiTestContext.Body(revision, "en", "de");
update["variables"] = new JsonArray(new JsonObject { ["name"] = "Changed", ["required"] = false });
update["translations"]![0]!["subject"] = "Edited English";
var response = await context.UpsertAsync(update);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
var saved = await context.ReadAsync();
Assert.Equal(saved.GetProperty("revision").GetGuid(), returned.GetProperty("revision").GetGuid());
Assert.NotEqual(revision, saved.GetProperty("revision").GetGuid());
Assert.Equal(original.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Equal(3, saved.GetProperty("translations").GetArrayLength());
Assert.Equal(uk.GetRawText(), saved.GetProperty("translations")[2].GetRawText());
Assert.Equal("Edited English", saved.GetProperty("translations")[1].GetProperty("subject").GetString());
Assert.Equal(original.GetProperty("translations")[0].GetProperty("id").GetGuid(), saved.GetProperty("translations")[1].GetProperty("id").GetGuid());
var legacy = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates/TestService/Welcome/uk");
Assert.Equal("Changed", legacy.GetProperty("data").GetProperty("variables")[0].GetProperty("name").GetString());
Assert.False(saved.GetProperty("variables")[0].GetProperty("required").GetBoolean());
}
[Fact]
public async Task ExplicitRemovalCanRemoveLastVersion_AndKeepDefinitionAndContract()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid());
request["removeLanguageCodes"] = new JsonArray("en", "uk", "de");
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.Empty(returned.GetProperty("translations").EnumerateArray());
var saved = await context.ReadAsync();
Assert.Equal(before.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Empty(saved.GetProperty("translations").EnumerateArray());
Assert.Single(saved.GetProperty("variables").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode);
}
[Fact]
public async Task RevisionsEnforceCreateOnlyAndUpdateOnly_WithoutPartialChanges()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var before = await context.ReadAsync();
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"), "Missing")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl("Missing"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.Empty))).StatusCode);
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
var revision = before.GetProperty("revision").GetGuid();
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk"))).StatusCode);
var otherRoot = $"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/upsert?key=Welcome";
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsJsonAsync(otherRoot, DefinitionApiTestContext.Body(null))).StatusCode);
}
[Theory]
[InlineData("missingVariables")]
[InlineData("nullVariables")]
[InlineData("missingTranslations")]
[InlineData("nullTranslations")]
[InlineData("nullVariable")]
[InlineData("nullTranslation")]
[InlineData("missingRequired")]
[InlineData("nonBooleanRequired")]
[InlineData("duplicateVariable")]
[InlineData("blankVariable")]
[InlineData("duplicateLanguage")]
[InlineData("unknownLanguage")]
[InlineData("blankContent")]
[InlineData("missingContent")]
[InlineData("duplicateRemoval")]
[InlineData("unknownRemoval")]
[InlineData("nullRemoval")]
[InlineData("overlap")]
public async Task InvalidUpdateDoesNotChangeContractTranslationsOrRevision(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = InvalidDefinitionApiBody.Create(scenario, before.GetProperty("revision").GetGuid());
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.False(error.GetProperty("success").GetBoolean());
Assert.Equal("InvalidRequest", error.GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentCreatesAndUpdatesHaveOneWinnerAndNoLostMerge()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(null, "en")), context.UpsertAsync(DefinitionApiTestContext.Body(null, "uk")));
Assert.Equal(new[] { HttpStatusCode.Created, HttpStatusCode.Conflict }, created.Select(r => r.StatusCode).Order().ToArray());
var before = await context.ReadAsync();
var revision = before.GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "de")), context.UpsertAsync(DefinitionApiTestContext.Body(revision, "ru")));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
var after = await context.ReadAsync();
Assert.Equal(2, after.GetProperty("translations").GetArrayLength());
var retained = before.GetProperty("translations")[0];
Assert.Contains(after.GetProperty("translations").EnumerateArray(), item => item.GetProperty("id").GetGuid() == retained.GetProperty("id").GetGuid());
}
[Fact]
public async Task RejectedCreateAndMalformedRequestsLeaveNoDefinition()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var definitions = context.ServiceRoot + "/email-template-definitions";
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(definitions + "/upsert", DefinitionApiTestContext.Body(null))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions + "?key=%20")).StatusCode);
var invalid = DefinitionApiTestContext.Body(null, "en", "unknown");
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalid)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
using var nullBody = new StringContent("null", System.Text.Encoding.UTF8, "application/json");
var response = await context.Client.PostAsync(context.DefinitionUrl(upsert: true), nullBody);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
var invalidRevision = DefinitionApiTestContext.Body(null);
invalidRevision["revision"] = "not-a-guid";
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalidRevision)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
}
[Fact]
public async Task ExactCaseCatalogCodesCanCoexistAndBeRemovedIndependently()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Languages = service.Languages.Append(new() { Code = "RU" }).ToList();
await services.SaveAsync(service, false);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "RU", "ru", "ru-RU"))).StatusCode);
var saved = await context.ReadAsync();
var removal = DefinitionApiTestContext.Body(saved.GetProperty("revision").GetGuid());
removal["variables"] = new JsonArray();
removal["removeLanguageCodes"] = new JsonArray("RU");
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(removal)).StatusCode);
var after = await context.ReadAsync();
Assert.Empty(after.GetProperty("variables").EnumerateArray());
Assert.Equal(new[] { "ru", "ru-RU" }, after.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
}
[Fact]
public async Task LegacyContentWriteInvalidatesLastReadRevision()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var body = DefinitionApiTestContext.Body(null, "en");
var legacy = (JsonObject)body["translations"]![0]!.DeepClone();
legacy["subject"] = "Legacy edit";
legacy["variables"] = body["variables"]!.DeepClone();
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", legacy)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid(), "uk"))).StatusCode);
var after = await context.ReadAsync();
Assert.Single(after.GetProperty("translations").EnumerateArray());
Assert.Equal("Legacy edit", after.GetProperty("translations")[0].GetProperty("subject").GetString());
}
[Theory]
[InlineData("Welcome + % # ? Привіт")]
[InlineData("Welcome%2Fexact")]
[InlineData("Welcome/child")]
public async Task EncodedKeysRoundTripExactly(string key)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await context.UpsertAsync(DefinitionApiTestContext.Body(null, "ru", "ru-RU"), key);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.NotNull(created.Headers.Location);
var located = await context.Client.GetFromJsonAsync<JsonElement>(created.Headers.Location);
Assert.Equal(key, located.GetProperty("data").GetProperty("key").GetString());
Assert.Equal(context.ServiceId, located.GetProperty("data").GetProperty("serviceId").GetGuid());
var read = await context.ReadAsync(key);
Assert.Equal(read.GetProperty("id").GetGuid(), located.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal(key, read.GetProperty("key").GetString());
Assert.Equal(new[] { "ru", "ru-RU" }, read.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key.ToLowerInvariant()))).StatusCode);
}
}