using HrynCo.NotificationService.DAL.Abstract.Repositories; 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 EmailTemplateMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture { [Fact] public async Task EquivalentContracts_PreserveLanguageIdsContentAndSharedVariables_AndRerunSafely() { var connection = await fixture.CreateDatabaseAsync(legacy: true); var en = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", """[{"name":"Name","required":true},{"name":"Link","required":false}]"""); var uk = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "UK", """[{"required":false,"name":"Link"},{"required":true,"name":"Name"}]"""); await MigrateAsync(connection); await MigrateAsync(connection); using var services = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); using var scope = services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var templates = await repository.GetAllAsync("TestService", "Welcome"); Assert.Equal(2, templates.Count); Assert.Contains(templates, t => t.Id == en && t.Subject == "Subject en" && t.HtmlBody == "

{{Name}}

"); Assert.Contains(templates, t => t.Id == uk && t.LanguageCode == "UK" && t.Subject == "Subject UK"); Assert.All(templates, t => Assert.Equal(2, t.Variables.Count)); await using var db = TemplateDatabaseFixture.CreateContext(connection); await db.GetService().MigrateAsync(TemplateDatabaseFixture.LegacyMigration); await db.Database.MigrateAsync(); Assert.Equal(en, (await repository.GetAsync("TestService", "Welcome", "en"))!.Id); } [Theory] [InlineData("[{\"name\":\"Other\",\"required\":true}]", "Conflicting shared variables")] [InlineData("{}", "Invalid template identity or variables array")] [InlineData("[null]", "Invalid variables shape")] [InlineData("[{\"name\":\"Name\",\"required\":\"yes\"}]", "Invalid variables shape")] [InlineData("[{\"name\":\"Name\",\"required\":true},{\"name\":\"Name\",\"required\":true}]", "Duplicate variable names")] public async Task InvalidLegacyData_StopsWithoutChangingSource_ThenCanRecover(string variables, string diagnostic) { var connection = await fixture.CreateDatabaseAsync(legacy: true); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", """[{"name":"Name","required":true}]"""); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "uk", variables); var error = await Assert.ThrowsAsync(() => MigrateAsync(connection)); Assert.Contains(diagnostic, error.MessageText); Assert.Contains("TestService", error.MessageText); Assert.DoesNotContain("Hello", error.MessageText); await TemplateDatabaseFixture.ExecuteAsync(connection, """ DO $$ BEGIN IF to_regclass('public.services') IS NOT NULL OR (SELECT count(*) FROM email_templates) <> 2 THEN RAISE EXCEPTION 'Migration partially changed source'; END IF; END $$; UPDATE email_templates SET variables = '[{"name":"Name","required":true}]'::jsonb; """); await MigrateAsync(connection); } [Fact] public async Task ExactLanguageCodes_RemainDistinctAfterMigration() { var connection = await fixture.CreateDatabaseAsync(legacy: true); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "ru", "[]"); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "ru-RU", "[]"); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "EN", "[]"); await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[]"); await MigrateAsync(connection); await TemplateDatabaseFixture.ExecuteAsync(connection, """ DO $$ BEGIN IF (SELECT count(*) FROM service_languages) <> 4 OR NOT EXISTS (SELECT 1 FROM service_languages WHERE code = 'ru-RU') OR NOT EXISTS (SELECT 1 FROM service_languages WHERE code = 'EN') THEN RAISE EXCEPTION 'Migration changed exact language codes'; END IF; END $$; """); } [Fact] public async Task InterruptedConversion_RollsBackAndCanBeRetried() { var connectionString = await fixture.CreateDatabaseAsync(legacy: true); await TemplateDatabaseFixture.InsertLegacyAsync(connectionString, "en", "[]"); await using var db = TemplateDatabaseFixture.CreateContext(connectionString); var script = db.GetService().GenerateScript(TemplateDatabaseFixture.LegacyMigration, options: MigrationsSqlGenerationOptions.NoTransactions); script = script.Replace("ALTER TABLE email_templates DROP COLUMN", "PERFORM pg_sleep(2); ALTER TABLE email_templates DROP COLUMN"); await using (var connection = new NpgsqlConnection(connectionString)) { await connection.OpenAsync(); await using var transaction = await connection.BeginTransactionAsync(); await using var command = new NpgsqlCommand("SET LOCAL statement_timeout = '500ms'; " + script, connection, transaction); var error = await Assert.ThrowsAsync(() => command.ExecuteNonQueryAsync()); Assert.Equal("57014", error.SqlState); await transaction.RollbackAsync(); } await MigrateAsync(connectionString); } private static async Task MigrateAsync(string connectionString) { await using var db = TemplateDatabaseFixture.CreateContext(connectionString); await db.Database.MigrateAsync(); } }