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 { [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().GetAsync(service.Id, "PushOnly"); Assert.Equal("Hello {{Name}}", Assert.Single(push!.Languages).Title); var email = await scope.ServiceProvider.GetRequiredService().GetDefinitionAsync(service.Id, "PushOnly"); Assert.Empty(email!.Variables); var list = await client.GetStringAsync($"/admin/services/{service.Id}"); Assert.Contains("Email", list); Assert.Contains("Push", list); Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(list, "PushOnly").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().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().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(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().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().SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Shared" }, true); await scope.ServiceProvider.GetRequiredService().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 { ["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().GetDefinitionAsync(service.Id, "Shared")); Assert.Null(await readScope.ServiceProvider.GetRequiredService().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().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 ReadServiceAsync(string connection) { using var provider = Provider(connection); using var scope = provider.CreateScope(); return (await scope.ServiceProvider.GetRequiredService().GetByNameAsync("TestService"))!; } private static void SetPush(Dictionary 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; } }