929 lines
36 KiB
C#
929 lines
36 KiB
C#
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;
|
|
|
|
|
|
/// <summary>
|
|
/// Tests de la base SQLite locale : création, insertion/lecture, refresh
|
|
/// snapshot atomique, rollback, données préservées.
|
|
/// </summary>
|
|
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<RendezVousDetailsDto>(local!.PayloadJson);
|
|
Assert.Equal("S.RDV.ANNU", dto?.Statut?.Key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<FormatException>(() => 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<string, bool> { ["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<InvalidOperationException>(
|
|
() => 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<OperationCanceledException>(() => 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<MobileSnapshotResponse>(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());
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|