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 InterimEmailTemplateMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture { [Fact] public async Task KnownInterimLayout_UpgradesInPlacePreservingEveryExistingFieldAndHistory() { var connection = await InterimEmailTemplateFixture.CreateAsync(fixture); var original = await SourceSnapshotAsync(connection, upgraded: false); await MigrateAsync(connection); Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: true)); var firstServices = await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"); await MigrateAsync(connection); Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s")); await using var db = TemplateDatabaseFixture.CreateContext(connection); var history = (await db.Database.GetAppliedMigrationsAsync()).ToList(); Assert.Equal(db.Database.GetMigrations().Append(InterimEmailTemplateFixture.MigrationId).Order(), history.Order()); Assert.Contains(InterimEmailTemplateFixture.MigrationId, history); using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); using var scope = provider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); Assert.Equal(Guid.Parse("66666666-6666-6666-6666-666666666666"), (await repository.GetAsync("InterimService", "Welcome", "ru-RU"))!.Id); Assert.Equal(Guid.Parse("77777777-7777-7777-7777-777777777777"), (await repository.GetAsync("InterimService", "Welcome", "ru"))!.Id); var definitions = scope.ServiceProvider.GetRequiredService(); Assert.Empty((await definitions.GetDefinitionAsync("InterimService", "Empty"))!.Languages); var services = await scope.ServiceProvider.GetRequiredService().GetAllAsync(); Assert.Contains(services, s => s.Name == "UnusedService" && s.Languages.Count == 0); Assert.All(services, s => Assert.NotEqual(Guid.Empty, s.Revision)); } [Theory] [InlineData("ALTER TABLE email_templates ADD COLUMN service_name varchar(100)")] [InlineData("ALTER TABLE email_templates ALTER COLUMN language_code TYPE varchar(11)")] [InlineData("DROP INDEX \"IX_email_templates_definition_id_language_code\"")] [InlineData("DROP INDEX \"IX_email_templates_definition_id_language_code\"; CREATE UNIQUE INDEX \"IX_email_templates_definition_id_language_code\" ON email_templates(id)")] [InlineData("ALTER TABLE email_templates DROP CONSTRAINT \"FK_email_templates_email_template_definitions_definition_id\"; ALTER TABLE email_templates ADD CONSTRAINT \"FK_email_templates_email_template_definitions_definition_id\" FOREIGN KEY(definition_id) REFERENCES email_template_definitions(id) ON DELETE RESTRICT")] [InlineData("DELETE FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20261017070315_EmailTemplateLocalizations'")] [InlineData("CREATE TABLE service_languages(id uuid)")] public async Task HybridOrUnknownLayout_FailsWithoutAdoptingOrChangingIt(string change) { var connection = await InterimEmailTemplateFixture.CreateAsync(fixture); await TemplateDatabaseFixture.ExecuteAsync(connection, change); var original = await SourceSnapshotAsync(connection, upgraded: false); var error = await Assert.ThrowsAsync(() => MigrateAsync(connection)); Assert.Contains("Unsupported email-template source layout", error.MessageText); Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: false)); } [Theory] [InlineData("UPDATE email_template_definitions SET variables = '{}'", "Invalid interim shared variables array")] [InlineData("UPDATE email_template_definitions SET variables = '[null]'", "Invalid interim shared variables shape")] [InlineData("UPDATE email_templates SET language_code = '' WHERE id = '66666666-6666-6666-6666-666666666666'", "Invalid blank identity")] public async Task InvalidInterimData_FailsBeforeSchemaChanges(string change, string diagnostic) { var connection = await InterimEmailTemplateFixture.CreateAsync(fixture); await TemplateDatabaseFixture.ExecuteAsync(connection, change); var original = await SourceSnapshotAsync(connection, upgraded: false); var error = await Assert.ThrowsAsync(() => MigrateAsync(connection)); Assert.Contains(diagnostic, error.MessageText); Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: false)); Assert.Equal("0", await ScalarAsync(connection, "SELECT count(*)::text FROM information_schema.columns WHERE table_name = 'services' AND column_name = 'revision'")); } [Fact] public async Task InterruptedInterimUpgrade_RollsBackAndCanBeRetried() { var connectionString = await InterimEmailTemplateFixture.CreateAsync(fixture); var original = await SourceSnapshotAsync(connectionString, upgraded: false); 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 language_code;", "PERFORM pg_sleep(2); ALTER TABLE email_templates DROP COLUMN language_code;"); 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); Assert.Equal("57014", (await Assert.ThrowsAsync(() => command.ExecuteNonQueryAsync())).SqlState); await transaction.RollbackAsync(); } Assert.Equal(original, await SourceSnapshotAsync(connectionString, upgraded: false)); await MigrateAsync(connectionString); Assert.Equal(original, await SourceSnapshotAsync(connectionString, upgraded: true)); } [Fact] public async Task InterimOrigin_DownIsBlockedBeforeAnyChanges() { var connection = await InterimEmailTemplateFixture.CreateAsync(fixture); await MigrateAsync(connection); var original = await SourceSnapshotAsync(connection, upgraded: true); await using var db = TemplateDatabaseFixture.CreateContext(connection); var error = await Assert.ThrowsAsync(() => db.GetService().MigrateAsync(TemplateDatabaseFixture.LegacyMigration)); Assert.Contains("Cannot downgrade an interim-origin database", error.MessageText); Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: true)); } private static async Task MigrateAsync(string connection) { await using var db = TemplateDatabaseFixture.CreateContext(connection); await db.Database.MigrateAsync(); } private static Task SourceSnapshotAsync(string connection, bool upgraded) { var versions = upgraded ? "SELECT (to_jsonb(t) - 'service_id' - 'service_language_id') || jsonb_build_object('language_code', l.code) AS value FROM email_templates t JOIN service_languages l ON l.id = t.service_language_id" : "SELECT to_jsonb(t) AS value FROM email_templates t"; return ScalarAsync(connection, $""" SELECT jsonb_build_object( 'services', (SELECT jsonb_agg(to_jsonb(s) - 'revision' ORDER BY id) FROM services s), 'definitions', (SELECT jsonb_agg(to_jsonb(d) ORDER BY id) FROM email_template_definitions d), 'versions', (SELECT jsonb_agg(value ORDER BY value->>'id') FROM ({versions}) q))::text """); } private static async Task ScalarAsync(string connectionString, string sql) { await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(); await using var command = new NpgsqlCommand(sql, connection); return (string)(await command.ExecuteScalarAsync())!; } }