using Omika.App.Local.Models; using Omika.Core.Contracts.Mobile.Sync; namespace Omika.Tests.Mobile.Local; /// /// Tests de l'Outbox locale : OperationId unique, conservation pendant retry, /// ordre CREATE → UPDATE → DELETE/ARCHIVE, REJECTED non renvoyé, récupération /// après restart. /// public class OutboxTests : LocalTestBase { private static OutboxOperation CreateOperation( SyncOperationType type, Guid? requestedGuid = null, OutboxState state = OutboxState.Pending) => new() { OperationId = Guid.NewGuid(), EntityType = "Client", OperationType = type, RequestedEntityGuid = requestedGuid ?? Guid.NewGuid(), Payload = "{}", State = state }; [Fact] public async Task NouvelleMutation_OperationIdUnique() { // Arrange var op1 = CreateOperation(SyncOperationType.Create); var op2 = CreateOperation(SyncOperationType.Create); // Act await OutboxRepository.AddAsync(op1); await OutboxRepository.AddAsync(op2); // Assert Assert.NotEqual(op1.OperationId, op2.OperationId); Assert.Equal(2, await OutboxRepository.CountPendingAsync()); } [Fact] public async Task OperationId_ConservePendantRetry() { // Arrange var op = CreateOperation(SyncOperationType.Create); await OutboxRepository.AddAsync(op); Guid originalId = op.OperationId; // Act : simuler un retry (mise à jour de l'état et du compteur). op.State = OutboxState.Sending; op.RetryCount = 1; await OutboxRepository.UpdateAsync(op); op.State = OutboxState.Pending; op.RetryCount = 2; await OutboxRepository.UpdateAsync(op); // Assert : l'OperationId n'a jamais changé. OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(originalId); Assert.NotNull(loaded); Assert.Equal(originalId, loaded.OperationId); Assert.Equal(2, loaded.RetryCount); } [Fact] public async Task Ordre_CreateAvantUpdate() { // Arrange var createOp = CreateOperation(SyncOperationType.Create); var updateOp = CreateOperation(SyncOperationType.Update); // Act : insérer l'UPDATE d'abord, puis le CREATE. await OutboxRepository.AddAsync(updateOp); await OutboxRepository.AddAsync(createOp); // Assert : GetReadyAsync retourne CREATE avant UPDATE. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); Assert.Equal(2, ready.Count); Assert.Equal(SyncOperationType.Create, ready[0].OperationType); Assert.Equal(SyncOperationType.Update, ready[1].OperationType); } [Fact] public async Task Ordre_UpdateAvantDelete() { // Arrange var updateOp = CreateOperation(SyncOperationType.Update); var deleteOp = CreateOperation(SyncOperationType.Delete); // Act : insérer le DELETE d'abord, puis l'UPDATE. await OutboxRepository.AddAsync(deleteOp); await OutboxRepository.AddAsync(updateOp); // Assert : GetReadyAsync retourne UPDATE avant DELETE. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); Assert.Equal(2, ready.Count); Assert.Equal(SyncOperationType.Update, ready[0].OperationType); Assert.Equal(SyncOperationType.Delete, ready[1].OperationType); } [Fact] public async Task Rejected_NonRenvoyeAutomatiquement() { // Arrange var op = CreateOperation(SyncOperationType.Create, state: OutboxState.Rejected); op.LastError = "ERR001: rejeté"; await OutboxRepository.AddAsync(op); // Act IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); // Assert : l'opération REJECTED n'est pas dans les prêtes à envoyer. Assert.Empty(ready); // L'opération est conservée pour diagnostic. OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId); Assert.NotNull(loaded); Assert.Equal(OutboxState.Rejected, loaded.State); Assert.Equal("ERR001: rejeté", loaded.LastError); } [Fact] public async Task RecuperationApresRestart_EtatTemporaire() { // Arrange : simuler un crash avec des opérations en Sending et WaitingResult. var sendingOp = CreateOperation(SyncOperationType.Create, state: OutboxState.Sending); var waitingOp = CreateOperation(SyncOperationType.Update, state: OutboxState.WaitingResult); await OutboxRepository.AddAsync(sendingOp); await OutboxRepository.AddAsync(waitingOp); // Act : récupération au démarrage. int recovered = await OutboxRepository.RecoverTemporaryStatesAsync(); // Assert Assert.Equal(2, recovered); OutboxOperation? sendingLoaded = await OutboxRepository.GetByIdAsync(sendingOp.OperationId); OutboxOperation? waitingLoaded = await OutboxRepository.GetByIdAsync(waitingOp.OperationId); Assert.Equal(OutboxState.Pending, sendingLoaded!.State); Assert.Equal(OutboxState.Pending, waitingLoaded!.State); } [Fact] public async Task GetTemporaryState_RetourneSendingEtWaitingResult() { // Arrange var sendingOp = CreateOperation(SyncOperationType.Create, state: OutboxState.Sending); var waitingOp = CreateOperation(SyncOperationType.Update, state: OutboxState.WaitingResult); var pendingOp = CreateOperation(SyncOperationType.Create, state: OutboxState.Pending); await OutboxRepository.AddAsync(sendingOp); await OutboxRepository.AddAsync(waitingOp); await OutboxRepository.AddAsync(pendingOp); // Act IReadOnlyList temporary = await OutboxRepository.GetTemporaryStateAsync(); // Assert Assert.Equal(2, temporary.Count); Assert.All(temporary, op => Assert.True( op.State == OutboxState.Sending || op.State == OutboxState.WaitingResult)); } [Fact] public async Task RecuperationEnvoiInterrompu_NeTouchePasLesEtatsSuivisParStatut() { // Arrange : un envoi interrompu (à récupérer) et une opération déjà reçue // par le serveur, en attente de résultat (à ne surtout pas réémettre). var interrupted = CreateOperation(SyncOperationType.Create, state: OutboxState.Sending); var accepted = CreateOperation(SyncOperationType.Update, state: OutboxState.Accepted); var waiting = CreateOperation(SyncOperationType.Update, state: OutboxState.WaitingResult); await OutboxRepository.AddAsync(interrupted); await OutboxRepository.AddAsync(accepted); await OutboxRepository.AddAsync(waiting); // Act int recovered = await OutboxRepository.RecoverSendingStatesAsync(); // Assert : seul l'envoi interrompu est remis en attente. Assert.Equal(1, recovered); OutboxOperation? interruptedLoaded = await OutboxRepository.GetByIdAsync(interrupted.OperationId); Assert.Equal(OutboxState.Pending, interruptedLoaded!.State); OutboxOperation? acceptedLoaded = await OutboxRepository.GetByIdAsync(accepted.OperationId); Assert.Equal(OutboxState.Accepted, acceptedLoaded!.State); OutboxOperation? waitingLoaded = await OutboxRepository.GetByIdAsync(waiting.OperationId); Assert.Equal(OutboxState.WaitingResult, waitingLoaded!.State); } [Fact] public async Task GetNotYetSent_RetourneUniquementPendingEtSending() { // Arrange : opérations à envoyer (Pending/Sending) et déjà envoyées // (Accepted/WaitingResult). var pending = CreateOperation(SyncOperationType.Create, state: OutboxState.Pending); var sending = CreateOperation(SyncOperationType.Create, state: OutboxState.Sending); var accepted = CreateOperation(SyncOperationType.Create, state: OutboxState.Accepted); var waiting = CreateOperation(SyncOperationType.Create, state: OutboxState.WaitingResult); await OutboxRepository.AddAsync(pending); await OutboxRepository.AddAsync(sending); await OutboxRepository.AddAsync(accepted); await OutboxRepository.AddAsync(waiting); // Act IReadOnlyList notYetSent = await OutboxRepository.GetNotYetSentAsync(); // Assert : seules les opérations non encore envoyées sont affichées. Assert.Equal(2, notYetSent.Count); Assert.All(notYetSent, op => Assert.True( op.State == OutboxState.Pending || op.State == OutboxState.Sending)); } [Fact] public async Task CountNotYetSent_ExclutEnvoyees_MaisCountPendingGardeTout() { // Arrange await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.Pending)); await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.Sending)); await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.Accepted)); await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.WaitingResult)); await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.Succeeded)); await OutboxRepository.AddAsync(CreateOperation(SyncOperationType.Create, state: OutboxState.Rejected)); // Act / Assert : le badge « à envoyer » ne compte que Pending/Sending, // tandis que le compteur moteur (travail restant) conserve les états suivis. Assert.Equal(2, await OutboxRepository.CountNotYetSentAsync()); Assert.Equal(4, await OutboxRepository.CountPendingAsync()); } }