feat(templates): add localized push templates and actions
Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery. Ref: IT-1627
This commit is contained in:
@@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
|
||||
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("No templates for this service yet.", templates);
|
||||
Assert.Contains("Create First Template", templates);
|
||||
Assert.Contains(root + "/templates/create", templates);
|
||||
Assert.Contains("template-key-filter", templates);
|
||||
@@ -129,7 +129,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
|
||||
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);
|
||||
Assert.DoesNotContain("No templates for this service yet.", html);
|
||||
}
|
||||
|
||||
private static void AssertConfirmationReset(string html) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ public sealed class AdminTemplatesIndexViewTests
|
||||
string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
|
||||
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
|
||||
Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
|
||||
Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view);
|
||||
Assert.Contains("new { serviceId, definitionId = email.Id, keyFilter }", view);
|
||||
Assert.DoesNotContain("/admin/templates", view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class ChannelTemplateModelTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationPreservesEmailAndSupportsAllChannelCombinations()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync(legacy: true);
|
||||
var originalId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[{\"name\":\"Name\",\"required\":true}]");
|
||||
await using var db = TemplateDatabaseFixture.CreateContext(connection);
|
||||
await db.Database.MigrateAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
Assert.False(db.Database.HasPendingModelChanges());
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
|
||||
var original = await emails.GetAsync("TestService", "Welcome", "en");
|
||||
Assert.Equal(originalId, original!.Id);
|
||||
Assert.True(Assert.Single(original.Variables).Required);
|
||||
await SeedPushAsync(connection, "Welcome");
|
||||
await SeedPushAsync(connection, "PushOnly");
|
||||
Assert.Null(await emails.GetAsync("TestService", "PushOnly", "en"));
|
||||
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
|
||||
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "EmailOnly" }, true);
|
||||
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "PushOnly" }, true);
|
||||
await TemplateDatabaseFixture.ExecuteAsync(connection, """
|
||||
DO $$ BEGIN
|
||||
IF (SELECT count(*) FROM templates) <> 3 OR (SELECT count(*) FROM push_template_definitions) <> 2 THEN
|
||||
RAISE EXCEPTION 'Unexpected shared catalog or channel count';
|
||||
END IF;
|
||||
IF (SELECT variables FROM email_template_definitions WHERE key = 'Welcome') =
|
||||
(SELECT variables FROM push_template_definitions WHERE key = 'Welcome') THEN
|
||||
RAISE EXCEPTION 'Channel variables must remain independent';
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingEmailRetainsPushAndBlocksServiceDeletion()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
|
||||
var email = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Shared" };
|
||||
await emails.SaveDefinitionAsync(email, true);
|
||||
await SeedPushAsync(connection, "Shared");
|
||||
Assert.True(await emails.DeleteDefinitionAsync(email.ServiceId == Guid.Empty
|
||||
? (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!.Id
|
||||
: email.ServiceId, email.Key, email.Revision));
|
||||
await TemplateDatabaseFixture.ExecuteAsync(connection, """
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM templates WHERE key = 'Shared') OR
|
||||
NOT EXISTS (SELECT 1 FROM push_templates) THEN
|
||||
RAISE EXCEPTION 'Email deletion removed push';
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
using var readScope = provider.CreateScope();
|
||||
var services = readScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
|
||||
var service = (await services.GetByNameAsync("TestService"))!;
|
||||
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LanguageDeletionCountsBothChannelsAndRetainsDefinitions()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await SeedPushAsync(connection, "PushOnly");
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
|
||||
var service = (await services.GetByNameAsync("TestService"))!;
|
||||
var language = service.Languages.Single(l => l.Code == "en");
|
||||
Assert.Equal(1, language.VersionCount);
|
||||
service.Languages = service.Languages.Where(l => l.Id != language.Id).ToList();
|
||||
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.SaveAsync(service, false));
|
||||
await services.RemoveLanguageAsync(service.Id, language.Id, 1, service.Revision);
|
||||
await TemplateDatabaseFixture.ExecuteAsync(connection, """
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM push_templates) OR
|
||||
NOT EXISTS (SELECT 1 FROM push_template_definitions WHERE key = 'PushOnly') THEN
|
||||
RAISE EXCEPTION 'Language deletion did not retain the empty push definition';
|
||||
END IF;
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseRejectsDuplicatePushLanguageAndForeignServiceLanguage()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await SeedPushAsync(connection, "PushOnly");
|
||||
await AssertSqlStateAsync(connection, "23505", """
|
||||
INSERT INTO push_templates
|
||||
SELECT gen_random_uuid(), definition_id, service_id, service_language_id, title, body, created, updated
|
||||
FROM push_templates LIMIT 1;
|
||||
""");
|
||||
await TemplateDatabaseFixture.ExecuteAsync(connection, """
|
||||
INSERT INTO services (id, name, revision, created) VALUES
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Other', gen_random_uuid(), now());
|
||||
INSERT INTO service_languages (id, service_id, code, created) VALUES
|
||||
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'en', now());
|
||||
""");
|
||||
await AssertSqlStateAsync(connection, "23503", """
|
||||
UPDATE push_templates SET service_language_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollbackRefusesToDiscardPushDefinitions()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await SeedPushAsync(connection, "PushOnly");
|
||||
await using var db = TemplateDatabaseFixture.CreateContext(connection);
|
||||
var error = await Assert.ThrowsAsync<PostgresException>(() => db.GetService<IMigrator>()
|
||||
.MigrateAsync("20261024080457_AddPushChannels"));
|
||||
Assert.Contains("Cannot roll back channel templates", error.MessageText);
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
private static Task SeedPushAsync(string connection, string key) => TemplateDatabaseFixture.ExecuteAsync(connection, $"""
|
||||
INSERT INTO templates (id, service_id, key, created)
|
||||
SELECT gen_random_uuid(), id, '{key}', now() FROM services WHERE name = 'TestService'
|
||||
ON CONFLICT (service_id, key) DO NOTHING;
|
||||
INSERT INTO push_template_definitions (id, service_id, key, revision, variables, created)
|
||||
SELECT gen_random_uuid(), service_id, key, gen_random_uuid(), '[]'::jsonb, now()
|
||||
FROM templates WHERE key = '{key}';
|
||||
INSERT INTO push_templates (id, definition_id, service_id, service_language_id, title, body, created)
|
||||
SELECT gen_random_uuid(), d.id, d.service_id, l.id, 'Title', 'Body', now()
|
||||
FROM push_template_definitions d JOIN service_languages l ON l.service_id = d.service_id
|
||||
WHERE d.key = '{key}' AND l.code = 'en';
|
||||
""");
|
||||
|
||||
private static ServiceProvider Provider(string connection) =>
|
||||
new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
|
||||
|
||||
private static async Task AssertSqlStateAsync(string connection, string state, string sql)
|
||||
{
|
||||
var error = await Assert.ThrowsAsync<PostgresException>(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql));
|
||||
Assert.Equal(state, error.SqlState);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
|
||||
{
|
||||
(Url: "/admin/services/create", Title: "Create Service", Form: "serviceForm"),
|
||||
(Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
|
||||
(Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"),
|
||||
(Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm")
|
||||
(Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"),
|
||||
(Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm")
|
||||
};
|
||||
foreach (var editor in editors)
|
||||
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
|
||||
@@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
|
||||
fields["VariablesJson"] = "{invalid";
|
||||
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
||||
Assert.Contains("Invalid variables JSON", errorHtml);
|
||||
AssertEditorLayout(errorHtml, "Create Email Template", "templateForm");
|
||||
AssertEditorLayout(errorHtml, "Create Template", "templateForm");
|
||||
Assert.Contains("<dl class=\"template-service-context\">", errorHtml);
|
||||
Assert.Contains("<dt>Service</dt>", errorHtml);
|
||||
Assert.Contains("<dd>TestService</dd>", errorHtml);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class PushChannelFormatTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, false, true)]
|
||||
[InlineData(2, true, true)]
|
||||
[InlineData(2, false, false)]
|
||||
[InlineData(3, true, false)]
|
||||
public async Task TestEndpointSelectsFormatAndValidatesBeforeProviderWithoutChangingChannel(int version, bool hasBinding, bool accepted)
|
||||
{
|
||||
await using var factory = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
|
||||
var firebase = new RecordingFirebasePushClient();
|
||||
await using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IFirebasePushClient>();
|
||||
services.AddSingleton<IFirebasePushClient>(firebase);
|
||||
}));
|
||||
using var client = app.CreateClient();
|
||||
var html = await client.GetStringAsync("/admin/push-channels/create");
|
||||
Assert.Contains("Version 2 — notification with a button", html);
|
||||
var fields = TemplateEditorTestFields.Read(html);
|
||||
var binding = hasBinding ? Guid.NewGuid() : (Guid?)null;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/test-send");
|
||||
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
|
||||
request.Content = JsonContent.Create(new
|
||||
{
|
||||
firebaseProjectId = "test-project", credentialsJson = PushChannelTestCredentials.Create(),
|
||||
recipientToken = "test-token", version, bindingId = binding, actionKey = "openBasket", buttonLabel = "Перейти в розділ"
|
||||
});
|
||||
var response = await client.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(accepted, result.GetProperty("success").GetBoolean());
|
||||
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
|
||||
if (accepted)
|
||||
{
|
||||
var sent = Assert.Single(firebase.Messages);
|
||||
Assert.Equal(version, sent.Version);
|
||||
Assert.Equal(binding, sent.BindingId);
|
||||
Assert.Equal("Перейти в розділ", sent.ButtonLabel);
|
||||
}
|
||||
else Assert.Empty(firebase.Messages);
|
||||
using var scope = app.Services.CreateScope();
|
||||
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Net;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class PushTemplateEditorTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task PushOnlyKeyCanAcquireEmailWithoutChangingPush_AndListHasOneRow()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "PushOnly";
|
||||
SetPush(fields, "en", "Hello {{Name}}", "A shared item changed.");
|
||||
fields["VariablesJson"] = "[{\"name\":\"Name\",\"required\":true}]";
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields))).StatusCode);
|
||||
var missing = await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/email?key=PushOnly");
|
||||
Assert.Contains("Create Email Template", missing);
|
||||
var emailFields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/templates/create?templateKey=PushOnly"));
|
||||
Assert.Equal("PushOnly", emailFields["Key"]);
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/templates/save", new FormUrlEncodedContent(emailFields))).StatusCode);
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var push = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "PushOnly");
|
||||
Assert.Equal("Hello {{Name}}", Assert.Single(push!.Languages).Title);
|
||||
var email = await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "PushOnly");
|
||||
Assert.Empty(email!.Variables);
|
||||
var list = await client.GetStringAsync($"/admin/services/{service.Id}");
|
||||
Assert.Contains("<th scope=\"col\">Email</th>", list);
|
||||
Assert.Contains("<th scope=\"col\">Push</th>", list);
|
||||
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(list, "<td>PushOnly</td>").Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("partial")]
|
||||
[InlineData("placeholder")]
|
||||
[InlineData("json")]
|
||||
[InlineData("catalog")]
|
||||
public async Task InvalidSaveRetainsInputAndDoesNotCreatePush(string scenario)
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient();
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "Invalid";
|
||||
SetPush(fields, "en", "Retained title", "Body");
|
||||
if (scenario == "partial") SetPush(fields, "en", "Retained title", "");
|
||||
if (scenario == "placeholder") SetPush(fields, "en", "Retained title", "{{Unknown}}");
|
||||
if (scenario == "json") fields["VariablesJson"] = "{}";
|
||||
if (scenario == "catalog") fields["Languages[0].LanguageCode"] = "foreign";
|
||||
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Retained title", html);
|
||||
Assert.Contains("validation-summary-errors", html);
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
Assert.Null(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingTabHasNoSideEffects_StaleSaveIsRejected_AndFinalVersionCanBeRemoved()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
Assert.Contains("Create Push Template", await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
using (var provider = Provider(connection))
|
||||
using (var scope = provider.CreateScope())
|
||||
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetDefinitionsAsync(service.Id));
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create?key=Shared"));
|
||||
SetPush(fields, "uk", "Title", "Body");
|
||||
await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
var saved = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
var stale = new Dictionary<string, string>(saved);
|
||||
SetPush(saved, "uk", "Updated title", "Updated body");
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(saved))).StatusCode);
|
||||
SetPush(stale, "uk", "Retained stale input", "Body");
|
||||
var error = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(stale));
|
||||
Assert.Contains("changed after you opened", await error.Content.ReadAsStringAsync());
|
||||
Assert.Contains("Retained stale input", await error.Content.ReadAsStringAsync());
|
||||
var current = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
SetPush(current, "uk", "", "");
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(current))).StatusCode);
|
||||
using var finalProvider = Provider(connection);
|
||||
using var finalScope = finalProvider.CreateScope();
|
||||
Assert.Empty((await finalScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"))!.Languages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmedPushDeletionKeepsEmail_AndPostRequiresAntiforgery()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
var service = await ReadServiceAsync(connection);
|
||||
using (var provider = Provider(connection))
|
||||
using (var scope = provider.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Shared" }, true);
|
||||
await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().SaveAsync(new PushTemplateDefinition { ServiceId = service.Id, Key = "Shared" }, true);
|
||||
}
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var route = $"/admin/services/{service.Id}/push-templates/delete";
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(route, new FormUrlEncodedContent(new Dictionary<string, string> { ["Key"] = "Shared" }))).StatusCode);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync(route + "?key=Shared"));
|
||||
fields["ConfirmDelete"] = "true";
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(route, new FormUrlEncodedContent(fields))).StatusCode);
|
||||
using var readProvider = Provider(connection);
|
||||
using var readScope = readProvider.CreateScope();
|
||||
Assert.NotNull(await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "Shared"));
|
||||
Assert.Null(await readScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"));
|
||||
}
|
||||
|
||||
private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task LocalizedActionsRoundTripAndInvalidEditsRetainInput(bool invalid)
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "WithButtons";
|
||||
SetPush(fields, "uk", "Потребою поділилися", "Перегляньте деталі");
|
||||
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
|
||||
var actionField = codeField[..^"LanguageCode".Length] + "ActionsJson";
|
||||
fields[actionField] = invalid ? "{\"buttons\":null}" : "{\"defaultAction\":\"openNeed\",\"buttons\":[{\"action\":\"openNeed\",\"label\":\"Переглянути\"}]}";
|
||||
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var saved = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "WithButtons");
|
||||
if (invalid)
|
||||
{
|
||||
Assert.Null(saved);
|
||||
Assert.Contains("validation-summary-errors", await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(fields[actionField], TemplateEditorTestFields.Read(await response.Content.ReadAsStringAsync())[actionField].Trim());
|
||||
return;
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var actions = PushTemplateActionValidation.Parse(Assert.Single(saved!.Languages).ActionsJson);
|
||||
Assert.Equal("openNeed", actions.DefaultAction);
|
||||
Assert.Equal("Переглянути", Assert.Single(actions.Buttons).Label);
|
||||
var reloaded = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=WithButtons"));
|
||||
var reloadedCodeField = reloaded.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
|
||||
Assert.Equal("openNeed", PushTemplateActionValidation.Parse(reloaded[reloadedCodeField[..^"LanguageCode".Length] + "ActionsJson"]).DefaultAction);
|
||||
}
|
||||
|
||||
private static async Task<NotificationServiceInfo> ReadServiceAsync(string connection)
|
||||
{
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
return (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!;
|
||||
}
|
||||
|
||||
private static void SetPush(Dictionary<string, string> fields, string code, string title, string body)
|
||||
{
|
||||
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == code).Key;
|
||||
var prefix = codeField[..^"LanguageCode".Length];
|
||||
fields[prefix + "Title"] = title;
|
||||
fields[prefix + "Body"] = body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class RecordingFirebasePushClient : IFirebasePushClient
|
||||
{
|
||||
public List<PushChannelTestMessage> Messages { get; } = [];
|
||||
|
||||
public Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||
{
|
||||
Messages.Add(message);
|
||||
return Task.FromResult(new PushDeliveryOutcome(null));
|
||||
}
|
||||
|
||||
public Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new PushDeliveryOutcome(null));
|
||||
|
||||
public Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -116,7 +116,7 @@ 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 for this service yet.", empty);
|
||||
Assert.Contains("No 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);
|
||||
|
||||
Reference in New Issue
Block a user