feat(notification): add admin rest management and deletion
Add revision-safe service and template APIs, channel mapping, and validated admin controls. Ref: IT-1601
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task DefinitionDeleteRequiresAntiforgeryAndConfirmation_AndPreservesFilter()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
|
||||
var definition = await context.ReadAsync();
|
||||
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
|
||||
var html = await client.GetStringAsync(url + "?keyFilter=Welcome");
|
||||
Assert.Contains("2 translation(s)", html);
|
||||
Assert.Contains("Languages: en, uk", html);
|
||||
var fields = TemplateEditorTestFields.Read(html);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(url, new FormUrlEncodedContent(new Dictionary<string, string> { ["ConfirmDelete"] = "true" }))).StatusCode);
|
||||
var unconfirmed = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
||||
Assert.Contains("Confirm deletion", unconfirmed);
|
||||
AssertConfirmationReset(unconfirmed);
|
||||
fields = TemplateEditorTestFields.Read(unconfirmed);
|
||||
fields["ConfirmDelete"] = "true";
|
||||
fields["Key"] = "Different";
|
||||
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
|
||||
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
|
||||
Assert.Equal($"/admin/services/{context.ServiceId}?key=Welcome", deleted.Headers.Location!.OriginalString);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/admin/services/{context.ServiceId}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleDefinitionConfirmationRefreshesCountRevisionAndUncheckedConfirmation()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
|
||||
var definition = await context.ReadAsync();
|
||||
var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete";
|
||||
var fields = TemplateEditorTestFields.Read(await context.Client.GetStringAsync(url));
|
||||
var oldRevision = fields["Revision"];
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(definition.GetProperty("revision").GetGuid(), "en"));
|
||||
fields["ConfirmDelete"] = "true";
|
||||
var html = await (await context.Client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
||||
Assert.Contains("revision changed", html);
|
||||
Assert.Contains("1 translation(s)", html);
|
||||
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(html)["Revision"]);
|
||||
AssertConfirmationReset(html);
|
||||
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
|
||||
var wrongScope = $"/admin/services/{Guid.NewGuid()}/templates/{definition.GetProperty("id").GetGuid()}/delete";
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(wrongScope)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsync(wrongScope, new FormUrlEncodedContent(fields))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceDeleteIsBlockedByEmptyDefinition_AndRequiresFreshConfirmationAfterRename()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
|
||||
var url = $"/admin/services/{context.ServiceId}/delete";
|
||||
var blocked = await client.GetStringAsync(url);
|
||||
Assert.Contains("1 template definition(s)", blocked);
|
||||
Assert.Contains("Email channels are retained", blocked);
|
||||
var fields = TemplateEditorTestFields.Read(blocked);
|
||||
fields["ConfirmDelete"] = "true";
|
||||
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
|
||||
var definition = await context.ReadAsync();
|
||||
await client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid());
|
||||
fields = TemplateEditorTestFields.Read(await client.GetStringAsync(url));
|
||||
var oldRevision = fields["Revision"];
|
||||
using (var scope = context.App.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
|
||||
var service = (await services.GetAsync(context.ServiceId))!;
|
||||
service.Name = "Renamed Before Delete";
|
||||
await services.SaveAsync(service, false);
|
||||
}
|
||||
fields["ConfirmDelete"] = "true";
|
||||
var stale = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
||||
Assert.Contains("revision changed", stale);
|
||||
Assert.Contains("Renamed Before Delete", stale);
|
||||
Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(stale)["Revision"]);
|
||||
AssertConfirmationReset(stale);
|
||||
fields = TemplateEditorTestFields.Read(stale);
|
||||
Assert.Contains("Confirm deletion", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
|
||||
fields["ConfirmDelete"] = "true";
|
||||
var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields));
|
||||
Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode);
|
||||
Assert.Equal("/admin/services", deleted.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyStatesOfferScopedFirstCreateOrClearFilterActions()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var root = $"/admin/services/{context.ServiceId}";
|
||||
var templates = await context.Client.GetStringAsync(root);
|
||||
Assert.Contains("No email templates for this service yet.", templates);
|
||||
Assert.Contains("Create First Template", templates);
|
||||
Assert.Contains(root + "/templates/create", templates);
|
||||
Assert.Contains("template-key-filter", templates);
|
||||
var filtered = await context.Client.GetStringAsync(root + "?key=Missing");
|
||||
Assert.Contains("No templates match this key.", filtered);
|
||||
Assert.Contains(">Clear filter</a>", filtered);
|
||||
Assert.DoesNotContain("Create First Template", filtered);
|
||||
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
|
||||
await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
|
||||
var services = await context.Client.GetStringAsync("/admin/services");
|
||||
Assert.Contains("No services yet.", services);
|
||||
Assert.Contains("Create First Service", services);
|
||||
Assert.Contains("aria-hidden=\"true\"", services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateLoadFailureDoesNotPretendTheServiceHasNoTemplates()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var failing = context.App.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
|
||||
services.AddScoped<IEmailTemplateDefinitionRepository, UnavailableEmailTemplateDefinitionRepository>()));
|
||||
using var client = failing.CreateClient();
|
||||
var html = await client.GetStringAsync($"/admin/services/{context.ServiceId}");
|
||||
Assert.Contains("Could not load templates", html);
|
||||
Assert.Contains("Templates are unavailable", html);
|
||||
Assert.DoesNotContain("Create First Template", html);
|
||||
Assert.DoesNotContain("No email templates for this service yet.", html);
|
||||
}
|
||||
|
||||
private static void AssertConfirmationReset(string html) =>
|
||||
Assert.DoesNotContain("checked", Regex.Match(html, "<input[^>]+type=\"checkbox\"[^>]*>").Value);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task LayoutLinksToApiDocumentationInNewTab_AndScalarIsAvailable()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var pages = new[] { "/admin/services", "/admin/channels", "/admin/services/create", "/admin/channels/create" };
|
||||
foreach (var page in pages)
|
||||
{
|
||||
var html = await context.Client.GetStringAsync(page);
|
||||
Assert.Single(Regex.Matches(html, "class=\"admin-api-documentation\""));
|
||||
Assert.Contains("href=\"/scalar/v1\" target=\"_blank\" rel=\"noopener noreferrer\"", html);
|
||||
Assert.Contains("aria-label=\"API documentation (opens in a new tab)\">API documentation</a>", html);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.GetAsync("/scalar/v1")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothListTablesHaveScopedGridBorders_AndRetainTemplateHeaderOnFilterMiss()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var services = await context.Client.GetStringAsync("/admin/services");
|
||||
Assert.Contains("table table-hover admin-data-table", services);
|
||||
var templates = await context.Client.GetStringAsync($"/admin/services/{context.ServiceId}?key=Missing");
|
||||
Assert.Contains("table table-hover admin-data-table service-template-grid", templates);
|
||||
Assert.Contains("class=\"template-actions\"", templates);
|
||||
Assert.Contains("No templates match this key.", templates);
|
||||
Assert.Contains("class=\"template-key-filter\"", templates);
|
||||
}
|
||||
[Fact]
|
||||
public async Task AllFourDataTablesShareTheGridClassAndScopedColumnHeaders()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
|
||||
{
|
||||
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
|
||||
});
|
||||
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||
var pages = new[] { "/admin/services", $"/admin/services/{context.ServiceId}", "/admin/channels", $"/admin/services/{context.ServiceId}/edit" };
|
||||
foreach (var page in pages)
|
||||
{
|
||||
var html = await context.Client.GetStringAsync(page);
|
||||
Assert.Single(Regex.Matches(html, "<table[^>]+admin-data-table"));
|
||||
Assert.Equal(Regex.Matches(html, "<th[ >]").Count, Regex.Matches(html, "<th scope=\"col\"").Count);
|
||||
Assert.DoesNotContain("<thead class=\"table-dark\">", html);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class DefinitionApiTestContext : IAsyncDisposable
|
||||
{
|
||||
public required TemplateWebApplicationFactory App { get; init; }
|
||||
public required HttpClient Client { get; init; }
|
||||
public required Guid ServiceId { get; init; }
|
||||
public string ServiceRoot => $"/api/v1/services/{ServiceId}";
|
||||
|
||||
public static async Task<DefinitionApiTestContext> CreateAsync(TemplateDatabaseFixture fixture)
|
||||
{
|
||||
var app = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
|
||||
var client = app.CreateClient();
|
||||
var response = await client.GetFromJsonAsync<JsonElement>("/api/v1/services?name=TestService");
|
||||
return new DefinitionApiTestContext { App = app, Client = client, ServiceId = response.GetProperty("data").GetProperty("id").GetGuid() };
|
||||
}
|
||||
|
||||
public string DefinitionUrl(string key = "Welcome", bool upsert = false) =>
|
||||
ServiceRoot + "/email-template-definitions" + (upsert ? "/upsert" : "") + "?key=" + Uri.EscapeDataString(key);
|
||||
|
||||
public Task<HttpResponseMessage> UpsertAsync(JsonNode body, string key = "Welcome") =>
|
||||
Client.PostAsJsonAsync(DefinitionUrl(key, upsert: true), body);
|
||||
|
||||
public async Task<JsonElement> ReadAsync(string key = "Welcome") =>
|
||||
(await Client.GetFromJsonAsync<JsonElement>(DefinitionUrl(key))).GetProperty("data");
|
||||
|
||||
public static JsonObject Body(Guid? revision, params string[] codes) => new()
|
||||
{
|
||||
["revision"] = revision?.ToString(),
|
||||
["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }),
|
||||
["translations"] = new JsonArray(codes.Select(code => (JsonNode)new JsonObject
|
||||
{
|
||||
["languageCode"] = code, ["subject"] = "Subject " + code,
|
||||
["htmlBody"] = "<p>Hello {{Name}}</p>", ["textBody"] = "Hello {{Name}}"
|
||||
}).ToArray())
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await App.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var request = Content("smtp.example.invalid", 2525, "test-only-user");
|
||||
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
|
||||
var original = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{id}")).GetProperty("data");
|
||||
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
|
||||
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
|
||||
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
|
||||
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
|
||||
var updated = Assert.Single(all.EnumerateArray());
|
||||
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
|
||||
Assert.Equal("updated-user", updated.GetProperty("settings").GetProperty("username").GetString());
|
||||
Assert.Equal(2526, updated.GetProperty("settings").GetProperty("port").GetInt32());
|
||||
Assert.Equal("test-only-value", updated.GetProperty("settings").GetProperty("password").GetString());
|
||||
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
|
||||
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
|
||||
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
|
||||
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
|
||||
Assert.Single(filtered.GetProperty("data").EnumerateArray());
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
|
||||
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
|
||||
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
|
||||
await channels.AddAsync(new EmailChannel
|
||||
{
|
||||
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
|
||||
});
|
||||
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
|
||||
Assert.Equal(2, all.GetArrayLength());
|
||||
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
|
||||
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nullSettings")]
|
||||
[InlineData("invalidPort")]
|
||||
[InlineData("blankHost")]
|
||||
[InlineData("unsupportedType")]
|
||||
[InlineData("blankService")]
|
||||
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
|
||||
switch (scenario)
|
||||
{
|
||||
case "nullSettings": body["settings"] = null; break;
|
||||
case "invalidPort": body["settings"]!["port"] = 0; break;
|
||||
case "blankHost": body["settings"]!["host"] = " "; break;
|
||||
case "unsupportedType": body["channelType"] = 99; break;
|
||||
case "blankService": body["serviceName"] = " "; break;
|
||||
}
|
||||
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
|
||||
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
|
||||
}
|
||||
|
||||
private static object Content(string host, int port, string username) => new
|
||||
{
|
||||
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
|
||||
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
|
||||
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+12
@@ -11,6 +11,18 @@ public sealed class FailingEmailTemplateDefinitionRepository(IEmailTemplateRepos
|
||||
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) =>
|
||||
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceName, key, ct);
|
||||
|
||||
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) =>
|
||||
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceId, key, ct);
|
||||
|
||||
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) =>
|
||||
throw new IOException("Simulated save failure.");
|
||||
|
||||
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
|
||||
((IEmailTemplateDefinitionRepository)inner).GetDefinitionsAsync(serviceId, ct);
|
||||
|
||||
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) =>
|
||||
((IEmailTemplateDefinitionRepository)inner).DeleteDefinitionAsync(serviceId, key, revision, ct);
|
||||
|
||||
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) =>
|
||||
throw new IOException("Simulated save failure.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public static class InvalidDefinitionApiBody
|
||||
{
|
||||
public static JsonObject Create(string scenario, Guid revision)
|
||||
{
|
||||
var body = DefinitionApiTestContext.Body(revision, "en");
|
||||
body["removeLanguageCodes"] = new JsonArray("uk");
|
||||
ApplyVariableScenario(scenario, body, (JsonArray)body["variables"]!);
|
||||
ApplyTranslationScenario(scenario, body, (JsonArray)body["translations"]!);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static void ApplyVariableScenario(string scenario, JsonObject body, JsonArray variables)
|
||||
{
|
||||
switch (scenario)
|
||||
{
|
||||
case "missingVariables": body.Remove("variables"); break;
|
||||
case "nullVariables": body["variables"] = null; break;
|
||||
case "nullVariable": variables.Add((JsonNode?)null); break;
|
||||
case "missingRequired": ((JsonObject)variables[0]!).Remove("required"); break;
|
||||
case "nonBooleanRequired": variables[0]!["required"] = "true"; break;
|
||||
case "duplicateVariable": variables.Add(variables[0]!.DeepClone()); break;
|
||||
case "blankVariable": variables[0]!["name"] = " "; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyTranslationScenario(string scenario, JsonObject body, JsonArray translations)
|
||||
{
|
||||
switch (scenario)
|
||||
{
|
||||
case "missingTranslations": body.Remove("translations"); break;
|
||||
case "nullTranslations": body["translations"] = null; break;
|
||||
case "nullTranslation": translations.Add((JsonNode?)null); break;
|
||||
case "duplicateLanguage": translations.Add(translations[0]!.DeepClone()); break;
|
||||
case "unknownLanguage": translations[0]!["languageCode"] = "RU"; break;
|
||||
case "blankContent": translations[0]!["textBody"] = " "; break;
|
||||
case "missingContent": ((JsonObject)translations[0]!).Remove("htmlBody"); break;
|
||||
case "duplicateRemoval": body["removeLanguageCodes"] = new JsonArray("uk", "uk"); break;
|
||||
case "unknownRemoval": body["removeLanguageCodes"] = new JsonArray("UK"); break;
|
||||
case "nullRemoval": body["removeLanguageCodes"] = new JsonArray((JsonNode?)null); break;
|
||||
case "overlap": body["removeLanguageCodes"] = new JsonArray("en"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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 ServiceCatalogApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateUpdateAndExactReadRoundTripLanguageIdentitiesAndRevision()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var body = new { name = "Api Created", languages = new[] { new { code = "en" }, new { code = "uk" } } };
|
||||
var response = await context.Client.PostAsJsonAsync("/api/v1/services", body);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
var service = (await context.Client.GetFromJsonAsync<JsonElement>(response.Headers.Location)).GetProperty("data");
|
||||
Assert.Equal(2, service.GetProperty("languages").GetArrayLength());
|
||||
Assert.All(service.GetProperty("languages").EnumerateArray(), l => Assert.NotEqual(Guid.Empty, l.GetProperty("id").GetGuid()));
|
||||
var url = "/api/v1/services/" + service.GetProperty("id").GetGuid();
|
||||
var update = UpdateBody(service);
|
||||
update["name"] = "Renamed API";
|
||||
update["languages"]![0]!["code"] = "en-GB";
|
||||
((JsonArray)update["languages"]!).Add(new JsonObject { ["code"] = "de" });
|
||||
var updated = await context.Client.PutAsJsonAsync(url, update);
|
||||
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
|
||||
var saved = (await updated.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
|
||||
Assert.NotEqual(service.GetProperty("revision").GetGuid(), saved.GetProperty("revision").GetGuid());
|
||||
Assert.Contains(saved.GetProperty("languages").EnumerateArray(), l => l.GetProperty("id").GetGuid() == service.GetProperty("languages")[0].GetProperty("id").GetGuid() && l.GetProperty("code").GetString() == "en-GB");
|
||||
Assert.Equal(3, saved.GetProperty("languages").GetArrayLength());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(url, update)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync("/api/v1/services", new { name = "Renamed API", languages = Array.Empty<object>() })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SnapshotOmissionOfUsedLanguageFailsWithoutChangingServiceOrDefinition()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
||||
var before = await ReadAsync(context);
|
||||
var definition = await context.ReadAsync();
|
||||
var update = UpdateBody(before);
|
||||
update["name"] = "Unsaved API rename";
|
||||
update["languages"] = new JsonArray(new JsonObject { ["code"] = "fr" });
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(context.ServiceRoot, update)).StatusCode);
|
||||
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
|
||||
Assert.Equal(definition.GetRawText(), (await context.ReadAsync()).GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrdinaryRemovalOnlyDeletesUnusedCatalogRows_AndReturnsNewRevisionOnRead()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
||||
var before = await ReadAsync(context);
|
||||
var used = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
|
||||
var unused = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "uk");
|
||||
var root = context.ServiceRoot + "/languages/";
|
||||
Assert.Equal(1, used.GetProperty("versionCount").GetInt32());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(root + used.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(root + unused.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
|
||||
var after = await ReadAsync(context);
|
||||
Assert.NotEqual(before.GetProperty("revision").GetGuid(), after.GetProperty("revision").GetGuid());
|
||||
Assert.DoesNotContain(after.GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "uk");
|
||||
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(root + Guid.NewGuid() + "?revision=" + after.GetProperty("revision").GetGuid())).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitDestructiveRemovalRequiresConfirmationRevisionAndCurrentCount_AndRetainsDefinitions()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "First");
|
||||
var service = await ReadAsync(context);
|
||||
var language = service.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
|
||||
var url = context.ServiceRoot + "/languages/" + language.GetProperty("id").GetGuid() + "/delete-with-versions";
|
||||
var body = new JsonObject { ["revision"] = service.GetProperty("revision").GetString(), ["expectedVersionCount"] = 1, ["confirmDelete"] = false };
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
|
||||
body.Remove("confirmDelete");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
|
||||
body["confirmDelete"] = true;
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Second");
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
|
||||
body["expectedVersionCount"] = 2;
|
||||
body["revision"] = Guid.NewGuid().ToString();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
|
||||
body["revision"] = service.GetProperty("revision").GetString();
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
|
||||
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
|
||||
Assert.Equal(2, definitions.GetArrayLength());
|
||||
Assert.All(definitions.EnumerateArray(), d => Assert.Empty(d.GetProperty("translations").EnumerateArray()));
|
||||
Assert.DoesNotContain((await ReadAsync(context)).GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "en");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nullLanguages")]
|
||||
[InlineData("duplicateCodes")]
|
||||
[InlineData("foreignId")]
|
||||
public async Task InvalidCreatePersistsNoService(string scenario)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var body = new JsonObject { ["name"] = "InvalidCreate", ["languages"] = new JsonArray(new JsonObject { ["code"] = "en" }) };
|
||||
switch (scenario)
|
||||
{
|
||||
case "nullLanguages": body["languages"] = null; break;
|
||||
case "duplicateCodes": ((JsonArray)body["languages"]!).Add(new JsonObject { ["code"] = "en" }); break;
|
||||
case "foreignId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync("/api/v1/services", body)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=InvalidCreate")).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("emptyRevision", HttpStatusCode.BadRequest)]
|
||||
[InlineData("missingRevision", HttpStatusCode.BadRequest)]
|
||||
[InlineData("foreignLanguageId", HttpStatusCode.Conflict)]
|
||||
public async Task InvalidUpdatePreservesCurrentServiceSnapshot(string scenario, HttpStatusCode expectedStatus)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var before = await ReadAsync(context);
|
||||
var body = UpdateBody(before);
|
||||
body["name"] = "Unsaved name";
|
||||
switch (scenario)
|
||||
{
|
||||
case "emptyRevision": body["revision"] = Guid.Empty.ToString(); break;
|
||||
case "missingRevision": body.Remove("revision"); break;
|
||||
case "foreignLanguageId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
|
||||
}
|
||||
Assert.Equal(expectedStatus, (await context.Client.PutAsJsonAsync(context.ServiceRoot, body)).StatusCode);
|
||||
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadAsync(DefinitionApiTestContext context) =>
|
||||
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
|
||||
|
||||
private static JsonObject UpdateBody(JsonElement service) => new()
|
||||
{
|
||||
["name"] = service.GetProperty("name").GetString(), ["revision"] = service.GetProperty("revision").GetString(),
|
||||
["languages"] = new JsonArray(service.GetProperty("languages").EnumerateArray().Select(l => (JsonNode)new JsonObject
|
||||
{ ["id"] = l.GetProperty("id").GetString(), ["code"] = l.GetProperty("code").GetString() }).ToArray())
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListsExposeRevisions_AndDefinitionsAreServiceScopedIncludingEmptyDefinitions()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null), "Empty");
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Populated");
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var other = new NotificationServiceInfo { Name = "OtherService" };
|
||||
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
|
||||
var services = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all")).GetProperty("data");
|
||||
Assert.Equal(2, services.GetArrayLength());
|
||||
Assert.All(services.EnumerateArray(), s => Assert.NotEqual(Guid.Empty, s.GetProperty("revision").GetGuid()));
|
||||
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
|
||||
Assert.Equal(new[] { "Empty", "Populated" }, definitions.EnumerateArray().Select(d => d.GetProperty("key").GetString()));
|
||||
Assert.Empty(definitions[0].GetProperty("translations").EnumerateArray());
|
||||
Assert.Single(definitions[1].GetProperty("translations").EnumerateArray());
|
||||
Assert.All(definitions.EnumerateArray(), d => Assert.Equal(context.ServiceId, d.GetProperty("serviceId").GetGuid()));
|
||||
var otherDefinitions = await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/services/{other.Id}/email-template-definitions/all");
|
||||
Assert.Empty(otherDefinitions.GetProperty("data").EnumerateArray());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/all")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await SeedChannelAsync(context);
|
||||
const string key = "Delete / %2F Привіт";
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"), key);
|
||||
var definition = await context.ReadAsync(key);
|
||||
var service = await ReadServiceAsync(context);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
var deleted = await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid());
|
||||
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
||||
var result = await deleted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(result.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Object, result.GetProperty("data").ValueKind);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key))).StatusCode);
|
||||
var afterDefinitionDeletion = await ReadServiceAsync(context);
|
||||
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
|
||||
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
|
||||
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
|
||||
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
|
||||
Assert.Empty(all.GetProperty("data").EnumerateArray());
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIsRevisionGuarded_AndEvenEmptyDefinitionsBlockServiceRemoval()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
|
||||
var definition = await context.ReadAsync();
|
||||
var service = await ReadServiceAsync(context);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + Guid.NewGuid())).StatusCode);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
|
||||
var edit = (await repository.GetAsync(context.ServiceId))!;
|
||||
edit.Name = "RenamedService";
|
||||
await repository.SaveAsync(edit, false);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal("RenamedService", (await ReadServiceAsync(context)).GetProperty("name").GetString());
|
||||
Assert.Empty((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("?revision=not-a-guid")]
|
||||
[InlineData("?revision=00000000-0000-0000-0000-000000000000")]
|
||||
public async Task InvalidServiceDeleteDoesNotRemoveAnything(string query)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var before = await ReadServiceAsync(context);
|
||||
var response = await context.Client.DeleteAsync(context.ServiceRoot + query);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
|
||||
Assert.Equal(before.GetRawText(), (await ReadServiceAsync(context)).GetRawText());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("?key=Welcome")]
|
||||
[InlineData("?key=Welcome&revision=not-a-guid")]
|
||||
[InlineData("?key=Welcome&revision=00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("?revision=11111111-1111-1111-1111-111111111111")]
|
||||
public async Task InvalidDefinitionDeleteDoesNotRemoveAnything(string query)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
||||
var before = await context.ReadAsync();
|
||||
var response = await context.Client.DeleteAsync(context.ServiceRoot + "/email-template-definitions" + query);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
|
||||
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentDefinitionUpdateAndDeleteHaveOneWinner()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
||||
var revision = (await context.ReadAsync()).GetProperty("revision").GetGuid();
|
||||
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk")), context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + revision));
|
||||
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentServiceDeleteAndDefinitionCreationCannotLeaveOrphans()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
var service = await ReadServiceAsync(context);
|
||||
var deletion = context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
|
||||
var creation = context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
||||
var responses = await Task.WhenAll(deletion, creation);
|
||||
var deleted = responses[0].StatusCode;
|
||||
var created = responses[1].StatusCode;
|
||||
Assert.True(deleted == HttpStatusCode.OK && created == HttpStatusCode.NotFound
|
||||
|| deleted == HttpStatusCode.Conflict && created == HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadServiceAsync(DefinitionApiTestContext context) =>
|
||||
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
|
||||
|
||||
private static async Task SeedChannelAsync(DefinitionApiTestContext context)
|
||||
{
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
|
||||
{
|
||||
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
|
||||
});
|
||||
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public sealed class ServiceTemplateNavigationTests(TemplateDatabaseFixture fixtu
|
||||
Assert.DoesNotContain("OtherTemplate", detail);
|
||||
Assert.Contains($"/admin/services/{service.Id}/templates/create", detail);
|
||||
Assert.Contains("data-state=\"missing\"", detail);
|
||||
Assert.Contains("No email templates found", await client.GetStringAsync($"/admin/services/{service.Id}?key=Missing"));
|
||||
Assert.Contains("No templates match this key.", await client.GetStringAsync($"/admin/services/{service.Id}?key=Missing"));
|
||||
Assert.Contains("Language codes", await client.GetStringAsync($"/admin/services/{service.Id}/edit"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/admin/services/{Guid.NewGuid()}" )).StatusCode);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
|
||||
var miss = await client.GetStringAsync(baseUrl + "?key=Absent");
|
||||
Assert.Matches("(?s)<th[^>]*>\\s*<form[^>]*method=\"get\"[^>]*class=\"template-key-filter\"", miss);
|
||||
Assert.Contains("value=\"Absent\"", miss);
|
||||
Assert.Contains("No email templates found", miss);
|
||||
Assert.Contains("No templates match this key.", miss);
|
||||
Assert.Contains("class=\"template-actions\">Action</th>", miss);
|
||||
Assert.Contains($"href=\"{baseUrl}\" class=\"btn btn-outline-secondary btn-sm\">Clear</a>", miss);
|
||||
var filtered = await client.GetStringAsync(baseUrl + "?key=Welcome");
|
||||
@@ -85,13 +85,13 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
|
||||
var empty = await client.GetStringAsync(baseUrl);
|
||||
Assert.Contains("No languages configured for this service.", empty);
|
||||
Assert.Contains("template-key-filter", empty);
|
||||
Assert.Contains("No email templates found", empty);
|
||||
Assert.Contains("No email templates for this service yet.", empty);
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
|
||||
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
|
||||
var withDefinition = await client.GetStringAsync(baseUrl);
|
||||
Assert.Contains("No configured languages", TemplateRow(withDefinition, "Empty"));
|
||||
Assert.DoesNotContain("data-state=", withDefinition);
|
||||
Assert.DoesNotContain("No email templates found", withDefinition);
|
||||
Assert.DoesNotContain("No templates match this key.", withDefinition);
|
||||
}
|
||||
|
||||
private static string TemplateRow(string html, string key)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class UnavailableEmailTemplateDefinitionRepository : IEmailTemplateDefinitionRepository
|
||||
{
|
||||
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
|
||||
}
|
||||
Reference in New Issue
Block a user