8e81572386
Add revision-safe service and template APIs, channel mapping, and validated admin controls. Ref: IT-1601
229 lines
15 KiB
C#
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);
|
|
}
|
|
}
|