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:
@@ -12,12 +12,16 @@ namespace HrynCo.NotificationService.Services.Tests.Push;
|
||||
|
||||
public sealed class FcmPushSenderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials()
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version)
|
||||
{
|
||||
var services = Substitute.For<INotificationServiceRepository>();
|
||||
var channels = Substitute.For<IPushChannelRepository>();
|
||||
var client = Substitute.For<IFirebasePushClient>();
|
||||
var templates = Substitute.For<IPushTemplateDefinitionRepository>();
|
||||
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
|
||||
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" };
|
||||
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
|
||||
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
||||
@@ -29,12 +33,16 @@ public sealed class FcmPushSenderTests
|
||||
private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token"
|
||||
});
|
||||
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns(json);
|
||||
client.SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
client.SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PushDeliveryOutcome(null));
|
||||
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
||||
var data = version == 1 ? Data(service.Name) with { Version = 1, Title = "Legacy title", Body = "Legacy body", TemplateKey = null, TemplateData = null } : Data(service.Name);
|
||||
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, CancellationToken.None);
|
||||
Assert.Null(result.Error);
|
||||
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(),
|
||||
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(),
|
||||
Arg.Is<FirebaseServiceAccountCredentials>(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any<CancellationToken>());
|
||||
await templates.Received(version == 1 ? 0 : 1).GetAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
if (version == 1)
|
||||
await client.Received().SendAsync(Arg.Any<Guid>(), Arg.Is<RenderedPushMessageData>(m => m.Title == "Legacy title" && m.Body == "Legacy body" && m.Buttons.Count == 0), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -46,6 +54,8 @@ public sealed class FcmPushSenderTests
|
||||
var services = Substitute.For<INotificationServiceRepository>();
|
||||
var channels = Substitute.For<IPushChannelRepository>();
|
||||
var client = Substitute.For<IFirebasePushClient>();
|
||||
var templates = Substitute.For<IPushTemplateDefinitionRepository>();
|
||||
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
|
||||
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
|
||||
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
||||
if (active)
|
||||
@@ -54,7 +64,7 @@ public sealed class FcmPushSenderTests
|
||||
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
|
||||
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns("secret-must-not-leak");
|
||||
}
|
||||
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
||||
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.DoesNotContain("secret-must-not-leak", result.Error);
|
||||
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
|
||||
@@ -72,7 +82,7 @@ public sealed class FcmPushSenderTests
|
||||
private static SendPushMessageData Data(string serviceName) => new()
|
||||
{
|
||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||
ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token",
|
||||
ServiceName = serviceName, Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
|
||||
TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class PushChannelTestServiceTests
|
||||
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
|
||||
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
|
||||
Assert.Null(outcome.Error);
|
||||
await firebase.Received(send ? 1 : 0).SendTestAsync("test-token", Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
|
||||
await firebase.Received(send ? 1 : 0).SendTestAsync(Arg.Is<PushChannelTestMessage>(m => m.Token == "test-token" && m.Version == 1), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
|
||||
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
|
||||
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public sealed class PushChannelTestServiceTests
|
||||
{
|
||||
var channels = Substitute.For<IPushChannelRepository>();
|
||||
var firebase = Client();
|
||||
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
|
||||
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
|
||||
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
|
||||
@@ -82,7 +82,7 @@ public sealed class PushChannelTestServiceTests
|
||||
var firebase = Substitute.For<IFirebasePushClient>();
|
||||
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PushDeliveryOutcome(null));
|
||||
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PushDeliveryOutcome(null));
|
||||
return firebase;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PushDeliveryTests
|
||||
Data = new SendPushMessageData
|
||||
{
|
||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64)
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, TokenHash = new string('a', 64)
|
||||
}
|
||||
};
|
||||
await service.CompleteFailureAsync(id, message, default);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
using Hrynco.RabbitMq;
|
||||
using NSubstitute;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.Tests.Push;
|
||||
|
||||
public sealed class PushPayloadVersionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void OriginalPayloadDeserializesWithoutTemplateFieldsAndKeepsExactLegacyOutput(bool explicitVersion)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToNode(new
|
||||
{
|
||||
registrationId = Guid.NewGuid(), bindingId = Guid.NewGuid(), userId = Guid.NewGuid(),
|
||||
serviceName = "Invemory", title = "Original title", body = "Original body",
|
||||
token = "device-token", tokenHash = new string('a', 64), expiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
})!;
|
||||
if (explicitVersion) json["version"] = 1;
|
||||
var data = json.Deserialize<SendPushMessageData>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
var message = new SendPushMessage { Data = data, CorrelationContext = new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() } };
|
||||
Assert.True(PushMessageValidator.IsValid(message));
|
||||
Assert.Null(data.TemplateData);
|
||||
var id = Guid.NewGuid();
|
||||
var payload = FirebasePushClient.BuildPayload(id, new RenderedPushMessageData(data, data.Title!, data.Body!, null, []));
|
||||
Assert.Equal(7, payload.Count);
|
||||
Assert.Equal("1", payload["version"]);
|
||||
Assert.Equal("/home", payload["route"]);
|
||||
Assert.Equal(id.ToString("D"), payload["messageId"]);
|
||||
Assert.Equal(data.BindingId.ToString("D"), payload["bindingId"]);
|
||||
Assert.Equal("Original title", payload["title"]);
|
||||
Assert.Equal("Original body", payload["body"]);
|
||||
Assert.False(payload.ContainsKey("actions"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
[InlineData(-1)]
|
||||
public async Task UnsupportedVersionNeverFallsBackOrContactsFirebase(int version)
|
||||
{
|
||||
var data = Data() with { Version = version, Title = "Legacy", Body = "Legacy" };
|
||||
Assert.False(PushMessageValidator.IsValidContent(data));
|
||||
var client = Substitute.For<IFirebasePushClient>();
|
||||
var sender = new FcmPushSender(Substitute.For<INotificationServiceRepository>(), Substitute.For<IPushChannelRepository>(), client, Substitute.For<IPushTemplateDefinitionRepository>());
|
||||
var result = await sender.SendAsync(Guid.NewGuid(), data, default);
|
||||
Assert.Contains("Unsupported push payload version", result.Error);
|
||||
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => FirebasePushClient.BuildPayload(Guid.NewGuid(), new RenderedPushMessageData(data, "Title", "Body", null, [])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectionActionHasNoObjectIdAndVersionTwoNeverContainsLegacyRoute()
|
||||
{
|
||||
var template = new PushTemplateDefinition { Key = "Section", Languages = [new PushTemplateTranslation
|
||||
{
|
||||
LanguageCode = "uk", Title = "Зміни", Body = "Перегляньте зміни",
|
||||
ActionsJson = "{\"defaultAction\":\"openBasket\",\"buttons\":[{\"action\":\"openBasket\",\"label\":\"Перейти в розділ\"}]}"
|
||||
}] };
|
||||
var data = Data();
|
||||
var rendered = PushTemplateRenderer.Render(template, data);
|
||||
Assert.Null(Assert.Single(rendered.Buttons).ObjectId);
|
||||
var payload = FirebasePushClient.BuildPayload(Guid.NewGuid(), rendered);
|
||||
Assert.Equal("2", payload["version"]);
|
||||
Assert.False(payload.ContainsKey("route"));
|
||||
using var actions = JsonDocument.Parse(payload["actions"]);
|
||||
var button = actions.RootElement.GetProperty("buttons")[0];
|
||||
Assert.Equal("openBasket", button.GetProperty("action").GetString());
|
||||
Assert.Equal("Перейти в розділ", button.GetProperty("label").GetString());
|
||||
Assert.False(button.TryGetProperty("objectId", out _));
|
||||
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, data with { TemplateData = data.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestSendBuildsActualButtonPayloadWithRealBindingAndPreservesSimpleLegacyTest()
|
||||
{
|
||||
var binding = Guid.NewGuid();
|
||||
var test = new PushChannelTestMessage { Token = "test-token", Version = 2, BindingId = binding, ActionKey = "openBasket", ButtonLabel = "Перейти в розділ" };
|
||||
var current = FirebasePushClient.BuildTestMessage(test);
|
||||
Assert.Null(current.Notification);
|
||||
Assert.Equal("2", current.Data["version"]);
|
||||
Assert.Equal(binding.ToString("D"), current.Data["bindingId"]);
|
||||
using var actions = JsonDocument.Parse(current.Data["actions"]);
|
||||
Assert.Equal("openBasket", actions.RootElement.GetProperty("buttons")[0].GetProperty("action").GetString());
|
||||
Assert.False(actions.RootElement.GetProperty("buttons")[0].TryGetProperty("objectId", out _));
|
||||
var legacy = FirebasePushClient.BuildTestMessage(test with { Version = 1, BindingId = null });
|
||||
Assert.NotNull(legacy.Notification);
|
||||
Assert.True(legacy.Data is null || legacy.Data.Count == 0);
|
||||
Assert.NotNull((test with { BindingId = null }).Validate());
|
||||
Assert.NotNull((test with { Version = 9 }).Validate());
|
||||
}
|
||||
|
||||
private static SendPushMessageData Data() => new()
|
||||
{
|
||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), ServiceName = "Invemory",
|
||||
Version = 2, TemplateKey = "Section", LanguageCode = "uk", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata> { ["openBasket"] = new() } }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.Tests.Push;
|
||||
|
||||
public sealed class PushTemplateRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void RendersLocalizedLabelsAndOpaqueObjectIdsWithoutReinterpretingValues()
|
||||
{
|
||||
var result = PushTemplateRenderer.Render(Template(), Message());
|
||||
Assert.Equal("Hello {{Injected}}", result.Title);
|
||||
var button = Assert.Single(result.Buttons);
|
||||
Assert.Equal("View {{Injected}}", button.Label);
|
||||
Assert.Equal("need-42", button.ObjectId);
|
||||
Assert.Equal("open", result.DefaultAction!.Action);
|
||||
using var payload = JsonDocument.Parse(FirebasePushClient.BuildPayload(Guid.NewGuid(), result)["actions"]);
|
||||
Assert.Equal("need-42", payload.RootElement.GetProperty("buttons")[0].GetProperty("objectId").GetString());
|
||||
Assert.Equal("open", payload.RootElement.GetProperty("defaultAction").GetProperty("action").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsesEnglishFallbackAndRejectsMissingFallback()
|
||||
{
|
||||
var message = Message() with { LanguageCode = "uk" };
|
||||
Assert.Equal("Hello {{Injected}}", PushTemplateRenderer.Render(Template(), message).Title);
|
||||
var template = Template();
|
||||
template.Languages = [];
|
||||
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingBindingOrConfiguredActionFailsInsteadOfSendingIncompleteNotification()
|
||||
{
|
||||
var message = Message();
|
||||
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
|
||||
{
|
||||
TemplateData = message.TemplateData! with { Bindings = new Dictionary<string, string>() }
|
||||
}));
|
||||
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
|
||||
{
|
||||
TemplateData = message.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() }
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncationKeepsUnicodeTextElementsIntact()
|
||||
{
|
||||
var message = Message() with
|
||||
{
|
||||
TemplateData = Message().TemplateData! with { Bindings = new Dictionary<string, string> { ["Name"] = string.Concat(Enumerable.Repeat("😀", 100)) } }
|
||||
};
|
||||
var result = PushTemplateRenderer.Render(Template(), message);
|
||||
Assert.True(result.Title.Length <= 100);
|
||||
Assert.EndsWith("…", result.Title);
|
||||
Assert.False(char.IsHighSurrogate(result.Title[^2]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"buttons\":null}")]
|
||||
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"\"}]}")]
|
||||
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"Open\"},{\"action\":\"open\",\"label\":\"Again\"}]}")]
|
||||
[InlineData("{\"defaultAction\":\"https://example.invalid\"}")]
|
||||
[InlineData("{\"unknown\":true}")]
|
||||
public void RejectsInvalidActionDefinitions(string json) =>
|
||||
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateActionValidation.Parse(json));
|
||||
|
||||
private static PushTemplateDefinition Template() => new()
|
||||
{
|
||||
Key = "Shared", Variables = [new EmailTemplateVariable { Name = "Name", Required = true }],
|
||||
Languages = [new PushTemplateTranslation
|
||||
{
|
||||
LanguageCode = "en", Title = "Hello {{Name}}", Body = "Shared item",
|
||||
ActionsJson = "{\"defaultAction\":\"open\",\"buttons\":[{\"action\":\"open\",\"label\":\"View {{Name}}\"}]}"
|
||||
}]
|
||||
};
|
||||
|
||||
private static SendPushMessageData Message() => new()
|
||||
{
|
||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||
ServiceName = "Example", Version = 2, TemplateKey = "Shared", LanguageCode = "en", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
TemplateData = new PushTemplateData
|
||||
{
|
||||
Bindings = new Dictionary<string, string> { ["Name"] = "{{Injected}}" },
|
||||
Actions = new Dictionary<string, PushActionMetadata> { ["open"] = new() { ObjectId = "need-42" } }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed class SendPushDeliveryValidatorTests
|
||||
Data = new SendPushMessageData
|
||||
{
|
||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||
ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token",
|
||||
ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
|
||||
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user