using Xunit; using Dapper; using Npgsql; using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; using KArtSell.BuildingBlocks.Reliability; namespace KArtSell.IdentityAccess.IntegrationTests.Features; [Collection("Database")] public class RegisterIdentityWithOutboxIntegrationTests : IAsyncLifetime { private readonly string _connectionString; private NpgsqlDataSource _dataSource = null!; private RegisterIdentitySql _sql = null!; private IOutboxWriter _outboxWriter = null!; public RegisterIdentityWithOutboxIntegrationTests() { _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=postgres;Password=postgres"; } public async Task InitializeAsync() { _dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build(); _sql = new RegisterIdentitySql(async () => await _dataSource.OpenConnectionAsync()); _outboxWriter = new DapperOutboxWriter(); await CleanupAsync(); } public async Task DisposeAsync() { await CleanupAsync(); await _dataSource.DisposeAsync(); } private async Task CleanupAsync() { using var conn = await _dataSource.OpenConnectionAsync(); await conn.ExecuteAsync("DELETE FROM public.identity WHERE email LIKE 'test-outbox-%'"); await conn.ExecuteAsync("DELETE FROM building_blocks.outbox_message WHERE event_type = 'IdentityCreated'"); } [Fact] public async Task RegisterIdentity_WithOutbox_WritesToBoth() { var email = "test-outbox-001@example.com"; var displayName = "Test Outbox 001"; var correlationId = Guid.NewGuid().ToString(); var identityId = Guid.NewGuid(); var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection ?? throw new InvalidOperationException("Failed to open connection"); await using (conn) { await using var transaction = await conn.BeginTransactionAsync(); // Create identity var createdId = await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None); Assert.NotEqual(Guid.Empty, createdId); // Write outbox message var outboxMessage = new OutboxMessage( MessageId: Guid.NewGuid(), EventType: "IdentityCreated", SchemaVersion: 1, PayloadJson: System.Text.Json.JsonSerializer.Serialize(new { identityId, email, displayName, correlationId }), CorrelationId: correlationId, OccurredAt: DateTimeOffset.UtcNow, PayloadHash: ComputeSha256("test-payload")); await _outboxWriter.AddAsync(conn, transaction, outboxMessage, CancellationToken.None); await transaction.CommitAsync(); // Verify identity was created var (returnedId, returnedEmail, _, _) = await _sql.GetIdentityAsync(createdId, CancellationToken.None); Assert.Equal(createdId, returnedId); Assert.Equal(email, returnedEmail); // Verify outbox message was written var outboxCount = await conn.QuerySingleAsync( "SELECT COUNT(1) FROM building_blocks.outbox_message WHERE correlation_id = @correlationId", new { correlationId }); Assert.Equal(1, outboxCount); } } [Fact] public async Task RegisterIdentity_RollbackOnError_RevertsBothIdentityAndOutbox() { var email = "test-outbox-002@example.com"; var displayName = "Test Outbox 002"; var correlationId = Guid.NewGuid().ToString(); var identityId = Guid.NewGuid(); var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection ?? throw new InvalidOperationException("Failed to open connection"); await using (conn) { await using var transaction = await conn.BeginTransactionAsync(); try { var createdId = await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None); Assert.NotEqual(Guid.Empty, createdId); // Write outbox message var outboxMessage = new OutboxMessage( MessageId: Guid.NewGuid(), EventType: "IdentityCreated", SchemaVersion: 1, PayloadJson: System.Text.Json.JsonSerializer.Serialize(new { identityId, email, displayName, correlationId }), CorrelationId: correlationId, OccurredAt: DateTimeOffset.UtcNow, PayloadHash: ComputeSha256("test-payload")); await _outboxWriter.AddAsync(conn, transaction, outboxMessage, CancellationToken.None); // Simulate error: force rollback throw new InvalidOperationException("Simulated error"); } catch (InvalidOperationException) { await transaction.RollbackAsync(); } // Verify identity was NOT created (rolled back) var identityExists = await conn.QuerySingleAsync( "SELECT COUNT(1) > 0 FROM public.identity WHERE email = @email", new { email }); Assert.False(identityExists); // Verify outbox message was NOT written (rolled back) var outboxCount = await conn.QuerySingleAsync( "SELECT COUNT(1) FROM building_blocks.outbox_message WHERE correlation_id = @correlationId", new { correlationId }); Assert.Equal(0, outboxCount); } } [Fact] public async Task RegisterIdentity_DuplicateEmail_NoOutboxWrite() { var email = "test-outbox-003@example.com"; var displayName = "Test Outbox 003"; var correlationId1 = Guid.NewGuid().ToString(); var correlationId2 = Guid.NewGuid().ToString(); var identityId1 = Guid.NewGuid(); var identityId2 = Guid.NewGuid(); var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection ?? throw new InvalidOperationException("Failed to open connection"); await using (conn) { // First registration: succeeds await using (var tx1 = await conn.BeginTransactionAsync()) { await _sql.CreateIdentityAsync(conn, tx1, identityId1, email, displayName, correlationId1, CancellationToken.None); var msg1 = new OutboxMessage(Guid.NewGuid(), "IdentityCreated", 1, System.Text.Json.JsonSerializer.Serialize(new { identityId1, email, displayName, correlationId1 }), correlationId1, DateTimeOffset.UtcNow, ComputeSha256("test")); await _outboxWriter.AddAsync(conn, tx1, msg1, CancellationToken.None); await tx1.CommitAsync(); } // Second registration: fails (duplicate email), should not write outbox await using (var tx2 = await conn.BeginTransactionAsync()) { var result = await _sql.CreateIdentityAsync(conn, tx2, identityId2, email, displayName, correlationId2, CancellationToken.None); Assert.Equal(Guid.Empty, result); // Conflict, returns empty // Don't write to outbox if creation failed await tx2.CommitAsync(); } // Verify only first outbox message exists var outboxCount = await conn.QuerySingleAsync( "SELECT COUNT(1) FROM building_blocks.outbox_message WHERE event_type = 'IdentityCreated'"); Assert.Equal(1, outboxCount); } } private static string ComputeSha256(string input) { using var hasher = System.Security.Cryptography.SHA256.Create(); var hash = hasher.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash); } }