Files
agrynco 81654c0d33 feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage.

Ref: IT-1601
2026-10-17 22:02:43 +03:00

107 lines
6.0 KiB
C#

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<TemplateDatabaseFixture>
{
[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<IEmailTemplateRepository>();
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 == "<p>{{Name}}</p>");
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<IMigrator>().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<PostgresException>(() => 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<IMigrator>().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<PostgresException>(() => 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();
}
}