using System.Net.Http; using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Repositories; using Omika.App.Local.Services; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Organisation; using Omika.Core.Contracts.Planning; namespace Omika.Tests.Mobile.Local; /// /// Tests du service de lecture de la liste des interventions depuis les données /// locales SQLite. Vérifie le tri déterministe, la recherche, l'ignorance des /// payloads invalides, la conservation du GUID public et l'absence d'appel API. /// public class InterventionLocalServiceTests : LocalTestBase { private readonly InterventionLocalService _service; public InterventionLocalServiceTests() { _service = new InterventionLocalService( InterventionRepository, ClientRepository, SiteRepository, DossierRepository, RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository, OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database); } private static string Serialize(T value) => JsonSerializer.Serialize(value); private static LocalIntervention CreateIntervention( Guid id, string reference, string? titre = null, DateTime? debut = null, string? etat = null, bool estArchive = false, Guid? clientId = null, Guid? siteId = null, Guid? dossierId = null, IReadOnlyList? collaborateurIds = null) { var dto = new InterventionDetailsDto { Id = id.ToString(), Reference = reference, Titre = titre, DatePrevueDebut = debut, Etat = etat is null ? null : new ReferenceValueDto { Label = etat }, EstArchive = estArchive, ClientId = clientId?.ToString(), SiteId = siteId?.ToString(), DossierId = dossierId?.ToString(), CollaborateurIds = collaborateurIds?.Select(g => g.ToString()).ToList() ?? [] }; return new LocalIntervention { Id = id, ClientId = clientId, SiteId = siteId, DossierId = dossierId, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalCollaborateur CreateCollaborateur(Guid id, string nom, string prenom) { var dto = new CollaborateurDetailsDto { Id = id.ToString(), Nom = nom, Prenom = prenom }; return new LocalCollaborateur { Id = id, PayloadJson = Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task GetInterventions_Aucune_ListeVide() { // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert Assert.Empty(result); } [Fact] public async Task GetInterventions_Une_RetourneLaListe() { // Arrange Guid id = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001", "Intervention A")); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert var item = Assert.Single(result); Assert.Equal(id, item.InterventionGuid); Assert.Equal("INT-001", item.Reference); Assert.Equal("Intervention A", item.Titre); } [Fact] public async Task GetInterventions_Plusieurs_TrieParDatePuisReference() { // Arrange await InterventionRepository.UpsertAsync(CreateIntervention( Guid.NewGuid(), "INT-002", debut: new DateTime(2026, 8, 15))); await InterventionRepository.UpsertAsync(CreateIntervention( Guid.NewGuid(), "INT-001", debut: new DateTime(2026, 8, 10))); await InterventionRepository.UpsertAsync(CreateIntervention( Guid.NewGuid(), "INT-003", debut: new DateTime(2026, 8, 10))); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert : tri déterministe par date prévue puis référence. Assert.Equal(3, result.Count); Assert.Equal("INT-001", result[0].Reference); Assert.Equal("INT-003", result[1].Reference); Assert.Equal("INT-002", result[2].Reference); } [Fact] public async Task GetInterventions_PayloadInvalide_Ignoree() { // Arrange await InterventionRepository.UpsertAsync(new LocalIntervention { Id = Guid.NewGuid(), PayloadJson = "not-json", UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert : l'entrée invalide est ignorée sans erreur. Assert.Empty(result); } [Fact] public async Task GetInterventions_GuidPublicConserve() { // Arrange Guid id = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001")); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert : le GUID public est conservé (jamais de PK HFSQL). Assert.Equal(id, Assert.Single(result).InterventionGuid); } [Fact] public async Task GetInterventions_RelationsResolues() { // Arrange Guid clientId = Guid.NewGuid(); Guid siteId = Guid.NewGuid(); Guid dossierId = Guid.NewGuid(); Guid collaborateurId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); await ClientRepository.UpsertAsync(new LocalClient { Id = clientId, PayloadJson = Serialize(new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = clientId.ToString(), Nom = "Client Alpha" }), UpdatedAtUtc = DateTimeOffset.UtcNow }); await SiteRepository.UpsertAsync(new LocalSite { Id = siteId, PayloadJson = Serialize(new Omika.Core.Contracts.Sites.SiteDetailsDto { Id = siteId.ToString(), Nom = "Site Beta" }), UpdatedAtUtc = DateTimeOffset.UtcNow }); await DossierRepository.UpsertAsync(new LocalDossier { Id = dossierId, PayloadJson = Serialize(new Omika.Core.Contracts.Interventions.DossierDetailsDto { Id = dossierId.ToString(), ReferenceMetier = "DOS-001" }), UpdatedAtUtc = DateTimeOffset.UtcNow }); await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean")); await InterventionRepository.UpsertAsync(CreateIntervention( interventionId, "INT-001", clientId: clientId, siteId: siteId, dossierId: dossierId, collaborateurIds: [collaborateurId])); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert : les relations Client/Site/Dossier/Collaborateur sont résolues localement. var item = Assert.Single(result); Assert.Equal("Client Alpha", item.ClientNom); Assert.Equal("Site Beta", item.SiteNom); Assert.Equal("DOS-001", item.DossierReference); Assert.Contains("Jean Dupont", item.Collaborateurs); } [Fact] public async Task GetInterventions_EstArchive_Conserve() { // Arrange await InterventionRepository.UpsertAsync(CreateIntervention(Guid.NewGuid(), "INT-001", estArchive: true)); // Act IReadOnlyList result = await _service.GetInterventionsAsync(); // Assert : le flag archivé est conservé pour le filtre. Assert.True(Assert.Single(result).EstArchive); } [Fact] public async Task CreateAsync_EcritLocalementEtEnqueueOutboxCreate() { // Arrange var payload = new InterventionSyncPayload { Reference = "INT-NEW", Titre = "Nouvelle intervention", DatePrevueDebut = new DateTime(2026, 9, 1, 9, 0, 0) }; // Act Guid requestedGuid = await _service.CreateAsync(payload); // Assert : l'entité locale est créée avec le GUID public (A). LocalIntervention? local = await InterventionRepository.GetByIdAsync(requestedGuid); Assert.NotNull(local); Assert.Equal("INT-NEW", JsonSerializer.Deserialize(local!.PayloadJson)?.Reference); // Assert : une opération Outbox CREATE est enqueue (RequestedEntityGuid = A). IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); var op = Assert.Single(ready); Assert.Equal(SyncOperationType.Create, op.OperationType); Assert.Equal(requestedGuid, op.RequestedEntityGuid); Assert.Equal("Intervention", op.EntityType); } [Fact] public async Task UpdateAsync_ModifieLocalementEtEnqueueOutboxUpdate() { // Arrange : une intervention locale existante. Guid id = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001", "Avant")); var payload = new InterventionSyncPayload { Titre = "Après" }; // Act Guid target = await _service.UpdateAsync(id, payload); // Assert : l'entité locale est mise à jour. LocalIntervention? local = await InterventionRepository.GetByIdAsync(id); Assert.NotNull(local); Assert.Equal("Après", JsonSerializer.Deserialize(local!.PayloadJson)?.Titre); // Assert : une opération Outbox UPDATE est enqueue. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); var op = Assert.Single(ready); Assert.Equal(SyncOperationType.Update, op.OperationType); Assert.Equal(target, op.RequestedEntityGuid); } [Fact] public async Task ArchiveAsync_ArchiveLocalementEtEnqueueOutboxArchive() { // Arrange : une intervention locale existante. Guid id = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001")); // Act Guid target = await _service.ArchiveAsync(id); // Assert : l'entité locale est archivée (EstArchive = true). LocalIntervention? local = await InterventionRepository.GetByIdAsync(id); Assert.NotNull(local); Assert.True(JsonSerializer.Deserialize(local!.PayloadJson)?.EstArchive); // Assert : une opération Outbox ARCHIVE est enqueue. IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); var op = Assert.Single(ready); Assert.Equal(SyncOperationType.Archive, op.OperationType); Assert.Equal(target, op.RequestedEntityGuid); } [Fact] public async Task CreateAsync_DeuxCreations_DeuxOperationIdsDistincts() { // Arrange var payloadA = new InterventionSyncPayload { Reference = "INT-A" }; var payloadB = new InterventionSyncPayload { Reference = "INT-B" }; // Act Guid guidA = await _service.CreateAsync(payloadA); Guid guidB = await _service.CreateAsync(payloadB); // Assert : deux identités mobiles distinctes et deux opérations Outbox. Assert.NotEqual(guidA, guidB); IReadOnlyList ready = await OutboxRepository.GetReadyAsync(); Assert.Equal(2, ready.Count); Assert.Equal(2, ready.Select(o => o.OperationId).Distinct().Count()); } [Fact] public async Task GetInterventionByIdAsync_RetourneRendezVousLie() { // Arrange : intervention + rendez-vous lié (colonne InterventionId peuplée, // comme après l'application du snapshot corrigé). Guid interventionGuid = Guid.NewGuid(); Guid rendezVousGuid = Guid.NewGuid(); await InterventionRepository.UpsertAsync(CreateIntervention(interventionGuid, "INT-RDV")); await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rendezVousGuid, InterventionId = interventionGuid, PayloadJson = Serialize(new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), InterventionId = interventionGuid.ToString("D"), Titre = "RDV lié", Debut = new DateTime(2026, 8, 19, 9, 0, 0) }), UpdatedAtUtc = DateTimeOffset.UtcNow }); // Act : repli local (le stub API retourne null) → MapToDetailAsync. InterventionDetail? detail = await _service.GetInterventionByIdAsync(interventionGuid); // Assert : la fiche intervention expose le rendez-vous lié. Assert.NotNull(detail); var rendezVous = Assert.Single(detail!.RendezVous); Assert.Equal(rendezVousGuid, rendezVous.RendezVousGuid); } // ===== Lecture d'un compte rendu : offline-first et échec réseau ===== /// /// Client API dont la lecture de compte rendu est configurable : nombre d'appels /// observés (preuve d'absence d'appel réseau) et exception à lever (reproduction /// du payload de production « Connection failure »). /// /// L'interface est redéclarée volontairement : GetCompteRenduByRendezVousAsync /// est une implémentation par défaut de , et une classe /// dérivée ne peut la remplacer qu'en réimplémentant explicitement l'interface. /// private sealed class CompteRenduApiClient : StubMobileApiClient, IMobileApiClient { public int CompteRenduCalls { get; private set; } public Exception? ExceptionToThrow { get; set; } public CompteRenduDto? Result { get; set; } public Task GetCompteRenduByRendezVousAsync( Guid rendezVousGuid, CancellationToken cancellationToken = default) { CompteRenduCalls++; return ExceptionToThrow is { } ex ? Task.FromException(ex) : Task.FromResult(Result); } } /// Service réel câblé sur le client API et la connectivité fournis. private InterventionLocalService CreateService(IMobileApiClient apiClient, bool online) { Network.Online = online; return new InterventionLocalService( InterventionRepository, ClientRepository, SiteRepository, DossierRepository, RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository, OutboxService, ReconciliationService, apiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database, Network); } /// Pose la copie locale d'un compte rendu (dernier état connu). private async Task SeedCompteRenduLocalAsync(Guid rendezVousGuid, string note) { var repository = new CompteRenduLocalRepository(Database); await repository.UpsertAsync(new LocalCompteRendu { RendezVousId = rendezVousGuid, PayloadJson = Serialize(new CompteRenduDto { Id = Guid.NewGuid().ToString("D"), RendezVousId = rendezVousGuid.ToString("D"), NoteCollaborateur = note }), UpdatedAtUtc = DateTimeOffset.UtcNow }); } /// /// Régression du ticket de production du 09/10 (message « Connection failure ») : /// la vérification d'existence du compte rendu — anti-doublon du bouton Créer — /// échouait et remontait jusqu'au ViewModel, qui la journalisait en P1. Un échec /// réseau ne doit plus jamais lever : au pire, l'absence est constatée localement. /// [Fact] public async Task GetCompteRenduByRendezVousAsync_EchecReseau_SansCopieLocale_NeLevePas() { var client = new CompteRenduApiClient { ExceptionToThrow = new HttpRequestException("Connection failure") }; InterventionLocalService service = CreateService(client, online: true); CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(Guid.NewGuid()); Assert.Null(result); Assert.Equal(1, client.CompteRenduCalls); } /// /// Échec réseau avec copie locale disponible : la copie reste exploitable, ce qui /// préserve l'anti-doublon (un compte rendu déjà connu ne doit pas être recréé). /// [Fact] public async Task GetCompteRenduByRendezVousAsync_EchecReseau_RetourneLaCopieLocale() { Guid rendezVousGuid = Guid.NewGuid(); await SeedCompteRenduLocalAsync(rendezVousGuid, "Note locale"); var client = new CompteRenduApiClient { ExceptionToThrow = new HttpRequestException("Connection failure") }; InterventionLocalService service = CreateService(client, online: true); CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(rendezVousGuid); Assert.NotNull(result); Assert.Equal("Note locale", result!.NoteCollaborateur); } /// /// Hors ligne, l'appel réseau n'est pas tenté du tout (aucune latence d'échec) : /// la copie locale est retournée directement. /// [Fact] public async Task GetCompteRenduByRendezVousAsync_HorsLigne_AucunAppelReseau() { Guid rendezVousGuid = Guid.NewGuid(); await SeedCompteRenduLocalAsync(rendezVousGuid, "Note locale"); var client = new CompteRenduApiClient(); InterventionLocalService service = CreateService(client, online: false); CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(rendezVousGuid); Assert.NotNull(result); Assert.Equal("Note locale", result!.NoteCollaborateur); Assert.Equal(0, client.CompteRenduCalls); } /// /// Hors ligne sans copie locale : « inconnu » est traité comme « absent » — choix /// assumé de l'offline-first (le compte rendu sera créé localement), sans réseau. /// [Fact] public async Task GetCompteRenduByRendezVousAsync_HorsLigne_SansCopieLocale_RetourneNull() { var client = new CompteRenduApiClient { Result = new CompteRenduDto { NoteCollaborateur = "Serveur" } }; InterventionLocalService service = CreateService(client, online: false); CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(Guid.NewGuid()); Assert.Null(result); Assert.Equal(0, client.CompteRenduCalls); } }