using System.Text.Json; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Clients; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Snapshot; using Omika.Core.Contracts.Planning; namespace Omika.Tests.Mobile.Local; /// /// Tests de la base SQLite locale : création, insertion/lecture, refresh /// snapshot atomique, rollback, données préservées. /// public class LocalSqliteTests : LocalTestBase { [Fact] public async Task CreationBaseLocale_InitialiseSchema() { // Arrange / Act : le schéma est initialisé dans le constructeur. // Assert : les tables techniques existent. object? outboxCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Outbox'"); object? mappingCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='IdentityMapping'"); object? syncStateCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='SyncState'"); object? docCacheCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='DocumentCache'"); object? bootstrapCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='LocalBootstrap'"); Assert.Equal(1L, Convert.ToInt64(outboxCount)); Assert.Equal(1L, Convert.ToInt64(mappingCount)); Assert.Equal(1L, Convert.ToInt64(syncStateCount)); Assert.Equal(1L, Convert.ToInt64(docCacheCount)); Assert.Equal(1L, Convert.ToInt64(bootstrapCount)); } [Fact] public async Task InsertionLecture_EntiteMetier() { // Arrange var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"test","Nom":"Client Test"}""" }; // Act await ClientRepository.UpsertAsync(client); // Assert LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id); Assert.NotNull(loaded); Assert.Equal(client.Id, loaded.Id); Assert.Equal(client.PayloadJson, loaded.PayloadJson); } [Fact] public async Task RefreshSnapshot_Atomique() { // Arrange : une personne existante (table encore portée par le snapshot). var personneA = new LocalPersonne { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Nom":"Ancien"}""" }; await PersonneRepository.UpsertAsync(personneA); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new ContactDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau" }] }; // Act await applier.ApplyAsync(snapshot); // Assert Assert.Equal(1, await PersonneRepository.CountAsync()); LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First(); Assert.NotEqual(personneA.Id, loaded.Id); Assert.Contains("Nouveau", loaded.PayloadJson); } [Fact] public async Task ApplyAsync_PreserveRendezVousGuids_ConserveLaMutationLocale() { // Arrange : un rendez-vous local déjà annulé (mutation pending non confirmée). Guid rendezVousGuid = Guid.NewGuid(); await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV annulé localement", Statut = new ReferenceValueDto { Key = "S.RDV.ANNU", Label = "Annulé" } }) }); // Snapshot serveur plus ancien : le rendez-vous y est encore « Confirmé ». var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, RendezVous = [ new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV annulé localement", Statut = new ReferenceValueDto { Key = "S.RDV.CONF", Label = "Confirmé" } } ] }; // Act : le snapshot est appliqué en préservant le rendez-vous en attente. await applier.ApplyAsync(snapshot, [rendezVousGuid]); // Assert : la mutation locale (Annulé) n'est pas écrasée. LocalRendezVous? local = await RendezVousRepository.GetByIdAsync(rendezVousGuid); Assert.NotNull(local); RendezVousDetailsDto? dto = JsonSerializer.Deserialize(local!.PayloadJson); Assert.Equal("S.RDV.ANNU", dto?.Statut?.Key); } /// /// Règle métier : un rendez-vous ARCHIVÉ ne monte JAMAIS dans le mobile. Il est /// écarté à l'import du snapshot et sa copie locale n'est pas conservée. /// [Fact] public async Task ApplyAsync_RendezVousArchive_EstEcarte() { // Arrange : une copie locale d'un rendez-vous d'hier et un snapshot qui // n'en contient plus que la version ARCHIVÉE. Guid rendezVousGuid = Guid.NewGuid(); DateTime hier = DateTime.Today.AddDays(-1); await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV archivé", Debut = hier.AddHours(9), Fin = hier.AddHours(10) }) }); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, RendezVous = [ new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV archivé", Debut = hier.AddHours(9), Fin = hier.AddHours(10), EstArchive = true } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : le rendez-vous archivé n'est ni importé ni conservé. Assert.Null(await RendezVousRepository.GetByIdAsync(rendezVousGuid)); Assert.Equal(0, await RendezVousRepository.CountAsync()); } /// /// Un rendez-vous de la fenêtre d'agenda ABSENT du snapshot (archivé côté /// serveur, retiré de l'agenda du collaborateur ou collaborateur retiré du /// groupe) ne doit pas être conservé : sa copie locale ne doit plus apparaître. /// [Fact] public async Task ApplyAsync_RendezVousDansLaFenetreAbsentDuSnapshot_EstRetire() { Guid rendezVousGuid = Guid.NewGuid(); DateTime hier = DateTime.Today.AddDays(-1); await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV retiré", Debut = hier.AddHours(9), Fin = hier.AddHours(10) }) }); var applier = new SnapshotApplier(Database); // Act : snapshot sans ce rendez-vous. await applier.ApplyAsync(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); // Assert Assert.Null(await RendezVousRepository.GetByIdAsync(rendezVousGuid)); } /// /// Une copie locale chargée À LA DEMANDE, HORS de la fenêtre d'agenda du /// snapshot, reste conservée : elle ne sera pas re-fournie par le snapshot et /// doit rester consultable hors connexion. /// [Fact] public async Task ApplyAsync_RendezVousHorsFenetreAbsentDuSnapshot_EstConserve() { Guid rendezVousGuid = Guid.NewGuid(); DateTime loin = DateTime.Today.AddDays(40); await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rendezVousGuid, PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto { Id = rendezVousGuid.ToString("D"), Titre = "RDV chargé à la demande", Debut = loin.AddHours(9), Fin = loin.AddHours(10) }) }); var applier = new SnapshotApplier(Database); // Act : snapshot sans ce rendez-vous (hors fenêtre d'agenda). await applier.ApplyAsync(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); // Assert Assert.NotNull(await RendezVousRepository.GetByIdAsync(rendezVousGuid)); } [Fact] public async Task ApplyAsync_RendezVous_PersisteColonnesDeReference() { // Arrange : un rendez-vous lié à une intervention, un client, un site et un dossier. Guid interventionGuid = Guid.NewGuid(); Guid clientGuid = Guid.NewGuid(); Guid siteGuid = Guid.NewGuid(); Guid dossierGuid = Guid.NewGuid(); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, RendezVous = [ new RendezVousDetailsDto { Id = Guid.NewGuid().ToString("D"), ClientId = clientGuid.ToString("D"), SiteId = siteGuid.ToString("D"), DossierId = dossierGuid.ToString("D"), InterventionId = interventionGuid.ToString("D"), Titre = "RDV lié", Debut = new DateTime(2026, 8, 19, 9, 0, 0) } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : les colonnes de référence typées sont persistées puis relues. LocalRendezVous? local = (await RendezVousRepository.GetAllAsync()).Single(); Assert.NotNull(local.InterventionId); Assert.Equal(interventionGuid, local.InterventionId!.Value); Assert.Equal(clientGuid, local.ClientId!.Value); Assert.Equal(siteGuid, local.SiteId!.Value); Assert.Equal(dossierGuid, local.DossierId!.Value); } [Fact] public async Task RefreshSnapshot_Erreur_Rollback() { // Arrange : une intervention existante (table encore portée par le snapshot). var interventionA = new LocalIntervention { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Titre":"Ancien"}""" }; await InterventionRepository.UpsertAsync(interventionA); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Interventions = [new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = "not-a-guid", Titre = "Invalide" }] }; // Act / Assert : Guid.Parse("not-a-guid") lève une exception → rollback. await Assert.ThrowsAsync(() => applier.ApplyAsync(snapshot)); // L'ancienne donnée est conservée. Assert.Equal(1, await InterventionRepository.CountAsync()); LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionA.Id); Assert.NotNull(loaded); Assert.Equal(interventionA.Id, loaded.Id); } [Fact] public async Task RefreshSnapshot_ConserveParametreMetier() { // Arrange : insérer une donnée dans une table non-snapshot (LocalBootstrap). await BootstrapRepository.SaveAsync(new LocalBootstrapState { ParametresMetier = [new Omika.Core.Contracts.Configuration.ParametreMetierDto { Code = "PARAM1", Valeur = "valeur-conservée" }] }); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [new ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Client" }] }; // Act await applier.ApplyAsync(snapshot); // Assert : la donnée technique est conservée. LocalBootstrapState? loaded = await BootstrapRepository.GetAsync(); Assert.NotNull(loaded); Assert.Single(loaded.ParametresMetier); Assert.Equal("PARAM1", loaded.ParametresMetier[0].Code); } [Fact] public async Task RefreshSnapshot_ConserveProfilDroits() { // Arrange await BootstrapRepository.SaveAsync(new LocalBootstrapState { Profil = new Omika.Core.Contracts.Mobile.Bootstrap.MobileProfileDto { Code = "admin" }, Droits = new Dictionary { ["read"] = true } }); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }; // Act await applier.ApplyAsync(snapshot); // Assert LocalBootstrapState? loaded = await BootstrapRepository.GetAsync(); Assert.NotNull(loaded); Assert.NotNull(loaded.Profil); Assert.Equal("admin", loaded.Profil.Code); Assert.Single(loaded.Droits); } [Fact] public async Task RefreshSnapshot_ConserveLicenceEtConfiguration() { // Arrange : licence et configuration locale dans LocalBootstrap. await BootstrapRepository.SaveAsync(new LocalBootstrapState { Licence = new Omika.Core.Contracts.Mobile.License.LicenseResponse { IsValid = true, CodeClient = "CLIENT01", NumeroLicence = "LIC-001" }, Configuration = new Omika.Core.Contracts.Mobile.Bootstrap.MobileConfigurationDto { // Configuration locale utile au mobile. } }); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }; // Act await applier.ApplyAsync(snapshot); // Assert : licence et configuration sont préservées après le refresh. LocalBootstrapState? loaded = await BootstrapRepository.GetAsync(); Assert.NotNull(loaded); Assert.NotNull(loaded.Licence); Assert.True(loaded.Licence.IsValid); Assert.Equal("CLIENT01", loaded.Licence.CodeClient); Assert.Equal("LIC-001", loaded.Licence.NumeroLicence); Assert.NotNull(loaded.Configuration); } [Fact] public async Task RefreshSnapshot_ConserveOutbox() { // Arrange var operation = new OutboxOperation { OperationId = Guid.NewGuid(), EntityType = "Client", OperationType = Omika.Core.Contracts.Mobile.Sync.SyncOperationType.Create, RequestedEntityGuid = Guid.NewGuid(), Payload = "{}", State = OutboxState.Pending }; await OutboxRepository.AddAsync(operation); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }; // Act await applier.ApplyAsync(snapshot); // Assert OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(operation.OperationId); Assert.NotNull(loaded); Assert.Equal(operation.OperationId, loaded.OperationId); } [Fact] public async Task RefreshSnapshot_ConserveCacheDocument() { // Arrange var entry = new DocumentCacheEntry { DocumentGuid = Guid.NewGuid(), LocalPath = "/tmp/doc.pdf", DownloadedAtUtc = DateTimeOffset.UtcNow, Size = 1234 }; await DocumentCacheRepository.UpsertAsync(entry); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }; // Act await applier.ApplyAsync(snapshot); // Assert DocumentCacheEntry? loaded = await DocumentCacheRepository.GetAsync(entry.DocumentGuid); Assert.NotNull(loaded); Assert.Equal(entry.LocalPath, loaded.LocalPath); } [Fact] public async Task RefreshSnapshot_ConserveMappingIdentites() { // Arrange var mapping = new IdentityMapping { EntityType = "Client", RequestedEntityGuid = Guid.NewGuid(), CanonicalEntityGuid = Guid.NewGuid() }; await IdentityMappingRepository.UpsertAsync(mapping); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }; // Act await applier.ApplyAsync(snapshot); // Assert IdentityMapping? loaded = await IdentityMappingRepository.GetAsync(mapping.EntityType, mapping.RequestedEntityGuid); Assert.NotNull(loaded); Assert.Equal(mapping.CanonicalEntityGuid, loaded.CanonicalEntityGuid); } [Fact] public async Task RefreshSnapshot_NImportePasSuivisTemps() { // Arrange : le snapshot ne porte plus les saisies de temps ; la table // locale SuiviTemps est un cache alimenté à la demande, jamais vidé. var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, SuivisTemps = [ new Omika.Core.Contracts.Planning.SuiviTempsDto { Id = Guid.NewGuid().ToString(), DureeMinutes = 60, Description = "Saisie test" } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : aucune saisie n'est importée (table devenue cache). Assert.Equal(0, await SuiviTempsRepository.CountAsync()); } [Fact] public async Task RefreshSnapshot_ImporteIntervention_ConserveReferences() { // Arrange : le correctif doit être générique (non spécifique à SuiviTemps). // Intervention possède des colonnes de référence structurées (ClientId, // SiteId, DossierId) qui doivent être persistées lors du refresh snapshot. var applier = new SnapshotApplier(Database); Guid interventionId = Guid.NewGuid(); Guid clientId = Guid.NewGuid(); Guid siteId = Guid.NewGuid(); Guid dossierId = Guid.NewGuid(); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Interventions = [ new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = interventionId.ToString(), ClientId = clientId.ToString(), SiteId = siteId.ToString(), DossierId = dossierId.ToString(), Titre = "Intervention test" } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : l'intervention est importée avec ses références typées. Assert.Equal(1, await InterventionRepository.CountAsync()); LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionId); Assert.NotNull(loaded); Assert.Equal(interventionId, loaded.Id); Assert.Equal(clientId, loaded.ClientId); Assert.Equal(siteId, loaded.SiteId); Assert.Equal(dossierId, loaded.DossierId); Assert.Contains("Intervention test", loaded.PayloadJson); } [Fact] public async Task RefreshSnapshot_ReferenceOptionnelleNull() { // Arrange : une référence optionnelle absente doit rester NULL (pas de // Guid.Empty ni de valeur inventée). var applier = new SnapshotApplier(Database); Guid interventionId = Guid.NewGuid(); Guid clientId = Guid.NewGuid(); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Interventions = [ new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = interventionId.ToString(), ClientId = clientId.ToString(), // SiteId et DossierId absents → NULL. Titre = "Sans références optionnelles" } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : les références optionnelles restent NULL. LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionId); Assert.NotNull(loaded); Assert.Equal(clientId, loaded.ClientId); Assert.Null(loaded.SiteId); Assert.Null(loaded.DossierId); } [Fact] public async Task RefreshSnapshot_ImporteToutesLesCollections() { // Arrange : un snapshot complet avec toutes les collections. var applier = new SnapshotApplier(Database); Guid clientId = Guid.NewGuid(); Guid personneId = Guid.NewGuid(); Guid siteId = Guid.NewGuid(); Guid documentId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); Guid dossierId = Guid.NewGuid(); Guid rendezVousId = Guid.NewGuid(); Guid collaborateurId = Guid.NewGuid(); Guid suiviId = Guid.NewGuid(); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [ new ClientDetailsDto { Id = clientId.ToString(), Nom = "Client" } ], Personnes = [ new ContactDto { Id = personneId.ToString(), Nom = "Personne", ClientId = clientId.ToString() } ], Sites = [ new Omika.Core.Contracts.Sites.SiteDetailsDto { Id = siteId.ToString(), Nom = "Site", ClientId = clientId.ToString() } ], Documents = [ new Omika.Core.Contracts.Documents.DocumentSummaryDto { Id = documentId.ToString(), Nom = "Doc.pdf", InterventionId = interventionId.ToString() } ], Interventions = [ new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = interventionId.ToString(), ClientId = clientId.ToString(), SiteId = siteId.ToString(), DossierId = dossierId.ToString(), Titre = "Intervention" } ], Dossiers = [ new Omika.Core.Contracts.Interventions.DossierDetailsDto { Id = dossierId.ToString(), Titre = "Dossier", ClientId = clientId.ToString() } ], RendezVous = [ new Omika.Core.Contracts.Planning.RendezVousDetailsDto { Id = rendezVousId.ToString(), Titre = "RDV", Debut = new DateTime(2026, 8, 10, 9, 0, 0), Fin = new DateTime(2026, 8, 10, 10, 0, 0), ClientId = clientId.ToString() } ], Collaborateurs = [ new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurId.ToString(), Nom = "Collab" } ], SuivisTemps = [ new Omika.Core.Contracts.Planning.SuiviTempsDto { Id = suiviId.ToString(), CollaborateurId = collaborateurId.ToString(), InterventionId = interventionId.ToString(), Date = new DateTime(2026, 8, 10), DureeMinutes = 60 } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : seules les collections portées par le snapshot sont importées. // Client / Dossier / Collaborateur / Site / SuiviTemps sont des caches // alimentés à la demande, purgés à chaque synchro : le snapshot ne les // fournit plus. Les MÉTADONNÉES DE DOCUMENTS sont, elles, désormais // importées avec le snapshot (documents des interventions de la fenêtre // d'agenda, pour un accès hors ligne dès le lancement) : le document // fourni ici est donc bien présent. Assert.Equal(0, await ClientRepository.CountAsync()); Assert.Equal(1, await PersonneRepository.CountAsync()); Assert.Equal(0, await SiteRepository.CountAsync()); Assert.Equal(1, await DocumentMetadataRepository.CountAsync()); Assert.Equal(1, await InterventionRepository.CountAsync()); Assert.Equal(0, await DossierRepository.CountAsync()); Assert.Equal(1, await RendezVousRepository.CountAsync()); Assert.Equal(0, await CollaborateurRepository.CountAsync()); Assert.Equal(0, await SuiviTempsRepository.CountAsync()); } [Fact] public async Task RefreshSnapshot_DeuxPersonnes_IdsDistincts_ImportOk() { // Arrange : deux Personnes avec des Id différents → import accepté. var applier = new SnapshotApplier(Database); Guid personneA = Guid.NewGuid(); Guid personneB = Guid.NewGuid(); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [ new ContactDto { Id = personneA.ToString(), Nom = "A" }, new ContactDto { Id = personneB.ToString(), Nom = "B" } ] }; // Act await applier.ApplyAsync(snapshot); // Assert : les deux Personnes sont importées. Assert.Equal(2, await PersonneRepository.CountAsync()); } [Fact] public async Task RefreshSnapshot_Personne_IdDuplique_RejetAvantEcriture() { // Arrange : une ancienne Personne déjà présente dans la base locale. Guid ancienId = Guid.NewGuid(); await PersonneRepository.UpsertAsync(new LocalPersonne { Id = ancienId, PayloadJson = """{"Id":"ancien","Nom":"Ancien"}""" }); var applier = new SnapshotApplier(Database); Guid duplicateId = Guid.NewGuid(); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [ new ContactDto { Id = duplicateId.ToString(), Nom = "P1" }, new ContactDto { Id = duplicateId.ToString(), Nom = "P2" } ] }; // Act / Assert : rejet AVANT toute écriture (aucun DELETE/INSERT partiel). InvalidOperationException ex = await Assert.ThrowsAsync( () => applier.ApplyAsync(snapshot)); Assert.Equal("Snapshot invalide : Personne.Id dupliqué.", ex.Message); // L'ancienne base est conservée intacte. Assert.Equal(1, await PersonneRepository.CountAsync()); LocalPersonne? loaded = await PersonneRepository.GetByIdAsync(ancienId); Assert.NotNull(loaded); Assert.Equal(ancienId, loaded.Id); } [Fact] public async Task RefreshSnapshot_PersonnesUniques_ToutesInserees() { // Arrange : un snapshot corrigé avec uniquement des Personnes uniques. var applier = new SnapshotApplier(Database); Guid[] ids = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = ids.Select(id => new ContactDto { Id = id.ToString(), Nom = "P" }).ToArray() }; // Act await applier.ApplyAsync(snapshot); // Assert : toutes les Personnes sont insérées. Assert.Equal(ids.Length, await PersonneRepository.CountAsync()); foreach (Guid id in ids) { Assert.NotNull(await PersonneRepository.GetByIdAsync(id)); } } [Fact] public async Task RefreshSnapshot_RepositoriesLisentNouvellesDonnees() { // Arrange : un snapshot avec une personne, puis un second snapshot avec une // personne différente. Les repositories doivent lire les nouvelles données. var applier = new SnapshotApplier(Database); Guid premierePersonneId = Guid.NewGuid(); var premierSnapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new ContactDto { Id = premierePersonneId.ToString(), Nom = "Premier" }] }; await applier.ApplyAsync(premierSnapshot); // Act : second refresh avec une personne différente. Guid secondePersonneId = Guid.NewGuid(); var secondSnapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Personnes = [new ContactDto { Id = secondePersonneId.ToString(), Nom = "Second" }] }; await applier.ApplyAsync(secondSnapshot); // Assert : le repository lit les nouvelles données (la première personne a // été remplacée, pas cumulée). Assert.Equal(1, await PersonneRepository.CountAsync()); LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First(); Assert.Equal(secondePersonneId, loaded.Id); Assert.Contains("Second", loaded.PayloadJson); } [Fact] public async Task RefreshSnapshot_Annulation_Rollback() { // Arrange : un client existant, puis un snapshot avec un token annulé. var clientA = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Nom":"Ancien"}""" }; await ClientRepository.UpsertAsync(clientA); var applier = new SnapshotApplier(Database); var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [new ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau" }] }; using var cts = new CancellationTokenSource(); cts.Cancel(); // Act / Assert : l'annulation est propagée → rollback, ancienne base conservée. await Assert.ThrowsAnyAsync(() => applier.ApplyAsync(snapshot, cancellationToken: cts.Token)); // L'ancienne donnée est conservée. Assert.Equal(1, await ClientRepository.CountAsync()); LocalClient? loaded = await ClientRepository.GetByIdAsync(clientA.Id); Assert.NotNull(loaded); Assert.Equal(clientA.Id, loaded.Id); } /// /// Test ciblé reproduisant le pipeline réel Desktop : /// API (sérialisation JSON) → MobileApiClient (désérialisation JSON avec /// JsonSerializerDefaults.Web) → SnapshotApplier.ApplyAsync → SQLite. /// Vérifie que les collections désérialisées depuis JSON sont réellement /// remplies et que les INSERT atteignent COMMIT. /// [Fact] public async Task RefreshSnapshot_DeserialisationJson_ImporteToutesLesCollections() { // Arrange : construire un snapshot complet (comme le ferait MobileSnapshotService). Guid clientId = Guid.NewGuid(); Guid personneId = Guid.NewGuid(); Guid siteId = Guid.NewGuid(); Guid documentId = Guid.NewGuid(); Guid interventionId = Guid.NewGuid(); Guid dossierId = Guid.NewGuid(); Guid rendezVousId = Guid.NewGuid(); Guid collaborateurId = Guid.NewGuid(); Guid suiviId = Guid.NewGuid(); var original = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [ new ClientDetailsDto { Id = clientId.ToString(), Nom = "Client" } ], Personnes = [ new ContactDto { Id = personneId.ToString(), Nom = "Personne", ClientId = clientId.ToString() } ], Sites = [ new Omika.Core.Contracts.Sites.SiteDetailsDto { Id = siteId.ToString(), Nom = "Site", ClientId = clientId.ToString() } ], Documents = [ new Omika.Core.Contracts.Documents.DocumentSummaryDto { Id = documentId.ToString(), Nom = "Doc.pdf", InterventionId = interventionId.ToString() } ], Interventions = [ new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = interventionId.ToString(), ClientId = clientId.ToString(), SiteId = siteId.ToString(), DossierId = dossierId.ToString(), Titre = "Intervention" } ], Dossiers = [ new Omika.Core.Contracts.Interventions.DossierDetailsDto { Id = dossierId.ToString(), Titre = "Dossier", ClientId = clientId.ToString() } ], RendezVous = [ new Omika.Core.Contracts.Planning.RendezVousDetailsDto { Id = rendezVousId.ToString(), Titre = "RDV", Debut = new DateTime(2026, 8, 10, 9, 0, 0), Fin = new DateTime(2026, 8, 10, 10, 0, 0), ClientId = clientId.ToString() } ], Collaborateurs = [ new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurId.ToString(), Nom = "Collab" } ], SuivisTemps = [ new Omika.Core.Contracts.Planning.SuiviTempsDto { Id = suiviId.ToString(), CollaborateurId = collaborateurId.ToString(), InterventionId = interventionId.ToString(), Date = new DateTime(2026, 8, 10), DureeMinutes = 60 } ] }; // Simuler l'API : sérialiser en JSON (camelCase, comme l'API ASP.NET). var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); string json = JsonSerializer.Serialize(original, jsonOptions); // Simuler le Desktop : désérialiser le JSON (comme MobileApiClient.GetSnapshotAsync). MobileSnapshotResponse? deserialized = JsonSerializer.Deserialize(json, jsonOptions); Assert.NotNull(deserialized); // Vérifier que les collections désérialisées sont réellement remplies. Assert.Equal(1, deserialized.Clients.Count); Assert.Equal(1, deserialized.Personnes.Count); Assert.Equal(1, deserialized.Sites.Count); Assert.Equal(1, deserialized.Documents.Count); Assert.Equal(1, deserialized.Interventions.Count); Assert.Equal(1, deserialized.Dossiers.Count); Assert.Equal(1, deserialized.RendezVous.Count); Assert.Equal(1, deserialized.Collaborateurs.Count); Assert.Equal(1, deserialized.SuivisTemps.Count); // Act : appliquer le snapshot désérialisé (comme le ferait SyncEngine). var applier = new SnapshotApplier(Database); await applier.ApplyAsync(deserialized); // Assert : seules les collections portées par le snapshot sont importées. // Client / Dossier / Collaborateur / Site / SuiviTemps sont des caches // alimentés à la demande, purgés à chaque synchro : le snapshot ne les // fournit plus. Les MÉTADONNÉES DE DOCUMENTS sont, elles, désormais // importées avec le snapshot (documents des interventions de la fenêtre // d'agenda, pour un accès hors ligne dès le lancement) : le document // fourni ici est donc bien présent. Assert.Equal(0, await ClientRepository.CountAsync()); Assert.Equal(1, await PersonneRepository.CountAsync()); Assert.Equal(0, await SiteRepository.CountAsync()); Assert.Equal(1, await DocumentMetadataRepository.CountAsync()); Assert.Equal(1, await InterventionRepository.CountAsync()); Assert.Equal(0, await DossierRepository.CountAsync()); Assert.Equal(1, await RendezVousRepository.CountAsync()); Assert.Equal(0, await CollaborateurRepository.CountAsync()); Assert.Equal(0, await SuiviTempsRepository.CountAsync()); } }