using System.Text.Json; using Omika.Core.Contracts.Mobile.Sync; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.Repositories.Technical; namespace Omika.Tests.Repositories.Technical; /// /// Tests unitaires du suivi de statut, de la purge de MobLog et du routage des /// connexions du SyncTransferRepository. /// /// Aucune base HFSQL réelle : les deux connexions physiques (Service pour TOSRV, /// WebService pour MobReception/MobLog) sont simulées par des fakes qui routent /// les requêtes vers des lignes en mémoire par table. /// public class SyncTransferRepositoryTests { private const string DeviceId = "device-1"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // ========================================================================= // GetStatusAsync // ========================================================================= [Fact] public async Task GetStatus_OperationInTosrv_ReturnsPending() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); service.TosrvRows = [ TosrvRow(operationId, DeviceId, retryCount: 2) ]; var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(operationId, DeviceId); Assert.NotNull(status); Assert.Equal(SyncStatus.Pending, status.Status); Assert.Equal(2, status.RetryCount); Assert.Equal(operationId, status.OperationId); Assert.Equal(DeviceId, status.DeviceId); } [Fact] public async Task GetStatus_OperationInMobReception_ReturnsPending() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); web.ReceptionRows = [ ReceptionRow(operationId, DeviceId, retryCount: 1) ]; var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(operationId, DeviceId); Assert.NotNull(status); Assert.Equal(SyncStatus.Pending, status.Status); Assert.Equal(1, status.RetryCount); } [Fact] public async Task GetStatus_SuccessInMobLog_ReturnsSuccessWithPublicGuids() { var operationId = Guid.NewGuid(); var requested = Guid.NewGuid(); var canonical = Guid.NewGuid(); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); web.LogRows = [ LogRow(operationId, DeviceId, SyncStatus.Success, requestedEntityGuid: requested, canonicalEntityGuid: canonical) ]; var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(operationId, DeviceId); Assert.NotNull(status); Assert.Equal(SyncStatus.Success, status.Status); Assert.Equal(requested, status.RequestedEntityGuid); Assert.Equal(canonical, status.CanonicalEntityGuid); Assert.NotNull(status.CompletedAt); } [Fact] public async Task GetStatus_RejectedInMobLog_ReturnsRejectedWithError() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); web.LogRows = [ LogRow(operationId, DeviceId, SyncStatus.Rejected, errorCode: "MAX_RETRY", errorMessage: "Rejet définitif") ]; var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(operationId, DeviceId); Assert.NotNull(status); Assert.Equal(SyncStatus.Rejected, status.Status); Assert.Equal("MAX_RETRY", status.ErrorCode); Assert.Equal("Rejet définitif", status.ErrorMessage); } [Fact] public async Task GetStatus_Unknown_ReturnsNull() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(Guid.NewGuid(), DeviceId); Assert.Null(status); } [Fact] public async Task GetStatus_OtherDevice_ReturnsNull_NoLeak() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); // L'opération existe mais appartient à un autre appareil. web.LogRows = [ LogRow(operationId, "autre-device", SyncStatus.Success) ]; var repo = new SyncTransferRepository(service, web); var status = await repo.GetStatusAsync(operationId, DeviceId); // Aucune information métier sensible ne doit fuiter vers un autre device. Assert.Null(status); } // ========================================================================= // PurgeLogsAsync // ========================================================================= [Fact] public async Task Purge_OldTerminalSyncLog_EmitsDeleteWithCutoff() { var cutoff = DateTime.UtcNow.AddDays(-31); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.PurgeLogsAsync(cutoff); var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog")); Assert.NotNull(delete); // Le cutoff est passé en paramètre. Assert.Equal(cutoff, delete.Parameters[0]); // Ciblage strict : uniquement les logs structurés Sync terminaux. // Les marqueurs sont passés en paramètres SQL (LIKE ?), pas littéralement // dans le SQL. On vérifie donc les valeurs des paramètres. Assert.Equal("%\"schemaVersion\"%", delete.Parameters[1]); Assert.Equal("%\"operationId\"%", delete.Parameters[2]); Assert.Equal($"%\"status\":\"{SyncStatus.Success}\"%", delete.Parameters[3]); Assert.Equal($"%\"status\":\"{SyncStatus.Rejected}\"%", delete.Parameters[4]); } [Fact] public async Task Purge_RecentTerminalSyncLog_Kept() { // Un log récent ne doit pas être éligible : le cutoff est dans le futur // par rapport à la date du log. On vérifie que le SQL DELETE est bien // émis avec un cutoff qui exclut les logs récents (le filtrage par date // est appliqué côté base). var cutoff = DateTime.UtcNow.AddDays(-1); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.PurgeLogsAsync(cutoff); var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog")); Assert.Equal(cutoff, delete.Parameters[0]); } [Fact] public async Task Purge_NonSyncLog_NeverDeleted() { // Le SQL de purge ne doit jamais cibler les logs non-Sync : il exige // la présence de "schemaVersion" et "operationId" (format structuré // propre au mécanisme de synchronisation mobile). var cutoff = DateTime.UtcNow.AddDays(-31); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.PurgeLogsAsync(cutoff); var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog")); // Les marqueurs "schemaVersion" et "operationId" sont passés en // paramètres SQL (LIKE ?), pas littéralement dans le SQL. Assert.Equal("%\"schemaVersion\"%", delete.Parameters[1]); Assert.Equal("%\"operationId\"%", delete.Parameters[2]); } [Fact] public async Task Purge_PendingSyncLog_NeverDeleted() { // Le SQL de purge ne doit jamais cibler les opérations PENDING : il ne // filtre que sur les statuts terminaux SUCCESS / REJECTED. var cutoff = DateTime.UtcNow.AddDays(-31); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.PurgeLogsAsync(cutoff); var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog")); // Les statuts sont passés en paramètres SQL (LIKE ?). On vérifie que // seuls SUCCESS et REJECTED sont ciblés, jamais PENDING. Assert.DoesNotContain(delete.Parameters, p => p is string s && s.Contains($"\"status\":\"{SyncStatus.Pending}\"")); Assert.Equal($"%\"status\":\"{SyncStatus.Success}\"%", delete.Parameters[3]); Assert.Equal($"%\"status\":\"{SyncStatus.Rejected}\"%", delete.Parameters[4]); } // ========================================================================= // Routage des connexions // ========================================================================= [Fact] public async Task InsertToSrv_UsesServiceConnection_NotWebService() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.InsertToSrvAsync(new SyncEnvelope { OperationId = Guid.NewGuid(), DeviceId = DeviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = 0, Payload = "{}" }); // L'INSERT TOSRV doit être émis sur la connexion Service. Assert.Contains(service.Commands, c => c.Sql.StartsWith("INSERT INTO TOSRV")); // Aucune commande ne doit être émise sur la connexion WebService. Assert.Empty(web.Commands); } [Fact] public async Task GetTerminalResult_UsesWebServiceConnection_NotService() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); web.LogRows = [ LogRow(operationId, DeviceId, SyncStatus.Success) ]; var repo = new SyncTransferRepository(service, web); var result = await repo.GetTerminalResultAsync(operationId); Assert.NotNull(result); // La lecture MobLog doit être émise sur la connexion WebService. Assert.Contains(web.Commands, c => c.Sql.Contains("FROM MobLog")); // Aucune commande ne doit être émise sur la connexion Service. Assert.Empty(service.Commands); } [Fact] public async Task WriteLog_UsesWebServiceConnection_NotService() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.WriteLogAsync(new SyncLogEntry { OperationId = Guid.NewGuid(), Status = SyncStatus.Success, DeviceId = DeviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = 0, DateUtc = DateTimeOffset.UtcNow }); // L'INSERT MobLog doit être émis sur la connexion WebService. Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog")); // Aucune commande ne doit être émise sur la connexion Service. Assert.Empty(service.Commands); } [Fact] public async Task GetPendingOperations_UsesWebServiceConnection_NotService() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.GetPendingOperationsAsync(); // La lecture MobReception doit être émise sur la connexion WebService. Assert.Contains(web.Commands, c => c.Sql.Contains("FROM MobReception")); // Aucune commande ne doit être émise sur la connexion Service. Assert.Empty(service.Commands); } [Fact] public async Task MarkSuccess_UsesWebServiceConnection_NotService() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.MarkSuccessAsync(new SyncLogEntry { OperationId = Guid.NewGuid(), Status = SyncStatus.Success, DeviceId = DeviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = 0, DateUtc = DateTimeOffset.UtcNow }); // Le DELETE MobReception et l'INSERT MobLog doivent être émis sur la // connexion WebService. Assert.Contains(web.Commands, c => c.Sql.StartsWith("DELETE FROM MobReception")); Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog")); // Aucune commande ne doit être émise sur la connexion Service. Assert.Empty(service.Commands); } [Fact] public async Task MarkRejected_UsesWebServiceConnection_NotService() { var service = new FakeServiceConnection(); var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); await repo.MarkRejectedAsync(new SyncLogEntry { OperationId = Guid.NewGuid(), Status = SyncStatus.Rejected, DeviceId = DeviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = 0, DateUtc = DateTimeOffset.UtcNow }); // L'UPDATE MobReception et l'INSERT MobLog doivent être émis sur la // connexion WebService. Assert.Contains(web.Commands, c => c.Sql.StartsWith("UPDATE MobReception")); Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog")); // Aucune commande ne doit être émise sur la connexion Service. Assert.Empty(service.Commands); } // ========================================================================= // TransferToReceptionAsync (inter-connexion) // ========================================================================= [Fact] public async Task TransferToReception_TosrvToMobReception_ThenDeleteTosrv() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); service.TosrvRows = [ TosrvRow(operationId, DeviceId, retryCount: 0) ]; var web = new FakeWebServiceConnection(); var repo = new SyncTransferRepository(service, web); int transferred = await repo.TransferToReceptionAsync(); Assert.Equal(1, transferred); // Lecture TOSRV sur la connexion Service. Assert.Contains(service.Commands, c => c.Sql.Contains("FROM TOSRV")); // Insertion MobReception sur la connexion WebService. Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobReception")); // Suppression TOSRV sur la connexion Service (après succès de l'insertion). Assert.Contains(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV")); } [Fact] public async Task TransferToReception_InsertionFails_TosrvNotLost() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); service.TosrvRows = [ TosrvRow(operationId, DeviceId, retryCount: 0) ]; var web = new FakeWebServiceConnection(); // L'insertion MobReception échoue. web.FailInsertMobReception = true; var repo = new SyncTransferRepository(service, web); await Assert.ThrowsAsync(() => repo.TransferToReceptionAsync()); // TOSRV ne doit PAS être supprimé : l'opération n'est pas perdue. Assert.DoesNotContain(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV")); } [Fact] public async Task TransferToReception_AlreadyInMobReception_DoesNotDuplicate() { var operationId = Guid.NewGuid(); var service = new FakeServiceConnection(); service.TosrvRows = [ TosrvRow(operationId, DeviceId, retryCount: 0) ]; var web = new FakeWebServiceConnection(); // L'opération existe déjà dans MobReception (crash après insertion avant // suppression TOSRV). Un nouveau cycle ne doit pas recréer MobReception. web.ReceptionRows = [ ReceptionRow(operationId, DeviceId, retryCount: 0) ]; var repo = new SyncTransferRepository(service, web); int transferred = await repo.TransferToReceptionAsync(); // Aucune nouvelle insertion MobReception (idempotence par OperationId). Assert.DoesNotContain(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobReception")); // TOSRV est nettoyé car l'opération est déjà connue dans MobReception. Assert.Contains(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV")); } // ========================================================================= // Helpers de construction de lignes // ========================================================================= private static Dictionary TosrvRow( Guid operationId, string deviceId, int retryCount) { var envelope = new SyncEnvelope { OperationId = operationId, DeviceId = deviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = retryCount, Payload = "{}" }; return new Dictionary { ["IDTOSRV"] = 1, ["TS_Date"] = DateTime.Now, ["TS_Mobile"] = deviceId, ["TS_CMD"] = "MOB.OP", ["TS_Buffer"] = null, ["TS_Lu"] = false, ["TS_JSON"] = JsonSerializer.Serialize(envelope, JsonOptions) }; } private static Dictionary ReceptionRow( Guid operationId, string deviceId, int retryCount) { var envelope = new SyncEnvelope { OperationId = operationId, DeviceId = deviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RetryCount = retryCount, Payload = "{}" }; return new Dictionary { ["MR_PKID"] = 1L, ["MR_IDToSRV"] = 1L, ["MR_JSON"] = JsonSerializer.Serialize(envelope, JsonOptions), ["MR_CMD"] = "MOB.OP", ["MR_Buffer"] = null, ["MR_NumSerie"] = null, ["MR_Fait"] = false, ["MR_DateHeureRecep"] = DateTime.Now }; } private static Dictionary LogRow( Guid operationId, string deviceId, SyncStatus status, Guid? requestedEntityGuid = null, Guid? canonicalEntityGuid = null, string? errorCode = null, string? errorMessage = null) { var entry = new SyncLogEntry { OperationId = operationId, Status = status, DeviceId = deviceId, EntityType = "Client", OperationType = SyncOperationType.Create, EntityGuid = Guid.NewGuid(), RequestedEntityGuid = requestedEntityGuid, CanonicalEntityGuid = canonicalEntityGuid, RetryCount = 0, ErrorCode = errorCode, ErrorMessage = errorMessage, DateUtc = DateTimeOffset.UtcNow }; var message = JsonSerializer.Serialize(new Dictionary { ["schemaVersion"] = 1, ["operationId"] = operationId, ["status"] = status.ToString(), ["deviceId"] = deviceId, ["entityType"] = "Client", ["operationType"] = "Create", ["entityGuid"] = entry.EntityGuid, ["requestedEntityGuid"] = requestedEntityGuid, ["canonicalEntityGuid"] = canonicalEntityGuid, ["retryCount"] = 0, ["errorCode"] = errorCode, ["errorMessage"] = errorMessage, ["completedAtUtc"] = DateTime.UtcNow }, JsonOptions); return new Dictionary { ["ML_PKID"] = 1L, ["ML_DateCreat"] = DateTime.Now, ["ML_Message"] = message, ["ML_Niveau"] = (sbyte)0 }; } // ========================================================================= // Fakes de connexion (Service et WebService) // ========================================================================= /// /// Fake de la connexion Service : route les requêtes TOSRV vers des lignes /// en mémoire et enregistre les commandes exécutées. /// private sealed class FakeServiceConnection : IServiceHfSqlConnection { public IReadOnlyList> TosrvRows { get; set; } = []; public List Commands { get; } = []; public HfsqlOptions Options => new(); public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed; public Task OpenAsync(CancellationToken ct = default) { State = HfsqlConnectionState.Open; return Task.CompletedTask; } public Task CloseAsync() { State = HfsqlConnectionState.Closed; return Task.CompletedTask; } public Task ExecuteScalarAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); return Task.FromResult(null); } public Task ExecuteNonQueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); return Task.FromResult(1); } public Task>> QueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); if (ContainsFrom(sql, "TOSRV")) return Task.FromResult(TosrvRows); return Task.FromResult>>([]); } public Task> GetColumnsAsync( string tableName, CancellationToken ct = default) => Task.FromResult>([]); public ValueTask DisposeAsync() { State = HfsqlConnectionState.Closed; return ValueTask.CompletedTask; } } /// /// Fake de la connexion WebService/Business : route les requêtes MobReception /// et MobLog vers des lignes en mémoire et enregistre les commandes exécutées. /// private sealed class FakeWebServiceConnection : IWebServiceHfSqlConnection { public IReadOnlyList> LogRows { get; set; } = []; public IReadOnlyList> ReceptionRows { get; set; } = []; public bool FailInsertMobReception { get; set; } public List Commands { get; } = []; public HfsqlOptions Options => new(); public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed; public Task OpenAsync(CancellationToken ct = default) { State = HfsqlConnectionState.Open; return Task.CompletedTask; } public Task CloseAsync() { State = HfsqlConnectionState.Closed; return Task.CompletedTask; } public Task ExecuteScalarAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); return Task.FromResult(null); } public Task ExecuteNonQueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); if (FailInsertMobReception && sql.StartsWith("INSERT INTO MobReception")) { throw new InvalidOperationException("Échec simulé de l'insertion MobReception."); } return Task.FromResult(1); } public Task>> QueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) { Commands.Add(new ExecutedCommand(sql, parameters ?? [])); if (ContainsFrom(sql, "MobLog")) return Task.FromResult(LogRows); if (ContainsFrom(sql, "MobReception")) return Task.FromResult(ReceptionRows); return Task.FromResult>>([]); } public Task> GetColumnsAsync( string tableName, CancellationToken ct = default) => Task.FromResult>([]); public ValueTask DisposeAsync() { State = HfsqlConnectionState.Closed; return ValueTask.CompletedTask; } } private static bool ContainsFrom(string sql, string table) { string marker = $"FROM {table}"; int index = sql.IndexOf(marker, StringComparison.OrdinalIgnoreCase); while (index >= 0) { int next = index + marker.Length; if (next >= sql.Length || (!char.IsLetterOrDigit(sql[next]) && sql[next] != '_')) { return true; } index = sql.IndexOf(marker, index + 1, StringComparison.OrdinalIgnoreCase); } return false; } private sealed record ExecutedCommand(string Sql, IReadOnlyList Parameters); }