using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Mobile.Documents; using Omika.Core.Contracts.Mobile.Interventions; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Contracts.Mobile.Snapshot; using Omika.Core.Contracts.Mobile.Sync; namespace Omika.Tests.Mobile.Local; /// /// Tests du cache local Interventions (recherche à la demande + favoris), /// au niveau service réel (InterventionLocalService + InterventionCacheRepository). /// Vérifie le comportement métier offline-first sans API réelle. /// public class InterventionOnDemandTests : LocalTestBase { [Fact] public async Task Search_Reussi_RemplaceDerniereRecherche() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "Intervention A"), Result("B", "Intervention B")] }; var service = CreateService(api); var result = await service.SearchAsync(new MobileInterventionSearchRequest()); Assert.Equal(2, result.Count); Assert.Equal(2, (await service.GetLastSearchAsync()).Count); } [Fact] public async Task Search_Echoue_ConservePrecedente() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); api.SearchException = new InvalidOperationException("offline"); await Assert.ThrowsAsync(() => service.SearchAsync(new MobileInterventionSearchRequest())); Assert.Single(await service.GetLastSearchAsync()); } [Fact] public async Task Search_ResultatsStockesLocalement_Redemarrage() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A"), Result("B", "B")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); // Nouvelle instance sur la même base (simule un redémarrage). var service2 = CreateService(api); Assert.Equal(2, (await service2.GetLastSearchAsync()).Count); } [Fact] public async Task PremiereOuverture_SansRecherche_ListeVide() { var service = CreateService(new ConfigurableApiClient()); Assert.Empty(await service.GetLastSearchAsync()); Assert.Empty(await service.GetPinnedAsync()); } [Fact] public async Task Pin_Persiste_SurvitANouvelleRecherche() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; await service.SetPinnedAsync(idA, true); // Nouvelle recherche remplaçant A par B. api.SearchResults = [Result("B", "B")]; await service.SearchAsync(new MobileInterventionSearchRequest()); Assert.Single(await service.GetPinnedAsync()); Assert.Equal(idA, (await service.GetPinnedAsync())[0].InterventionGuid); } [Fact] public async Task Unpin_DisparaitDesFavoris() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; await service.SetPinnedAsync(idA, true); Assert.Single(await service.GetPinnedAsync()); await service.SetPinnedAsync(idA, false); Assert.Empty(await service.GetPinnedAsync()); } [Fact] public async Task Favoris_DisponiblesOffline() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; await service.SetPinnedAsync(idA, true); // Aucun appel API : lecture directe du cache. var pinned = await service.GetPinnedAsync(); Assert.Single(pinned); Assert.True(pinned[0].IsPinned); } [Fact] public async Task RefreshPinned_UneSeuleRequeteBulk() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A"), Result("B", "B")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); foreach (var item in await service.GetLastSearchAsync()) { await service.SetPinnedAsync(item.InterventionGuid, true); } api.ByIdsResults = [Result("A", "A refresh"), Result("B", "B refresh")]; await service.RefreshPinnedAsync(); Assert.Equal(1, api.ByIdsCallCount); Assert.Equal(2, api.LastIds?.Count); } [Fact] public async Task RefreshPinned_Echoue_ConserveCache() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "Titre original")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; await service.SetPinnedAsync(idA, true); api.ByIdsException = new InvalidOperationException("offline"); await Assert.ThrowsAsync(() => service.RefreshPinnedAsync()); var pinned = await service.GetPinnedAsync(); Assert.Single(pinned); Assert.Equal("Titre original", pinned[0].Titre); } [Fact] public async Task RefreshDetail_MetAJourLeCache() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "Titre original")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; api.ByIdsResults = [Result("A", "Titre rafraîchi", idA)]; await service.RefreshInterventionAsync(idA); var cached = await service.GetLastSearchAsync(); Assert.Equal("Titre rafraîchi", cached[0].Titre); } [Fact] public async Task SnapshotApplier_NeSupprimePasLeCache() { var api = new ConfigurableApiClient { SearchResults = [Result("A", "A")] }; var service = CreateService(api); await service.SearchAsync(new MobileInterventionSearchRequest()); Guid idA = (await service.GetLastSearchAsync())[0].InterventionGuid; await service.SetPinnedAsync(idA, true); // Un refresh snapshot général (sans interventions) ne touche pas le cache. var applier = new SnapshotApplier(Database); await applier.ApplyAsync(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); Assert.Single(await service.GetLastSearchAsync()); Assert.Single(await service.GetPinnedAsync()); } private InterventionLocalService CreateService(ConfigurableApiClient api) => new InterventionLocalService( InterventionRepository, ClientRepository, SiteRepository, DossierRepository, RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository, OutboxService, ReconciliationService, api, InterventionCacheRepository, ContextInterventionCacheRepository, Database); private static MobileInterventionSearchResult Result(string reference, string titre, Guid? id = null) => new MobileInterventionSearchResult { Detail = new InterventionDetailsDto { Id = (id ?? Guid.NewGuid()).ToString(), Titre = titre, Reference = reference }, NbRendezVous = 1, SiteNom = "Site", ClientNom = "Client", ContactNom = "Contact" }; private sealed class ConfigurableApiClient : IMobileApiClient { public List SearchResults { get; set; } = []; public List ByIdsResults { get; set; } = []; public Exception? SearchException { get; set; } public Exception? ByIdsException { get; set; } public int ByIdsCallCount { get; set; } public IReadOnlyList? LastIds { get; set; } public string? AccessToken { get; set; } public Task ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetBootstrapAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task SendOperationAsync(SyncOperation operation, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetStatusAsync(Guid operationId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetSnapshotAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> SearchInterventionsAsync(MobileInterventionSearchRequest request, CancellationToken cancellationToken = default) { if (SearchException is not null) { throw SearchException; } return Task.FromResult>(SearchResults); } public Task> GetInterventionsByIdsAsync(MobileInterventionsByIdsRequest request, CancellationToken cancellationToken = default) { ByIdsCallCount++; LastIds = request.Ids; if (ByIdsException is not null) { throw ByIdsException; } return Task.FromResult>(ByIdsResults); } } }