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());
}
}