using System.Text.Json; using Omika.Core.Compatibility; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Runtime; using Omika.Core.Services.Mobile; using Omika.Worker; using Xunit; namespace Omika.Tests.Mobile; /// /// Tests du handler d'opération de synchronisation SuiviTemps. /// Le handler est COMMUN (aucune condition Batik/Omika) : il délègue le /// traitement physique à et l'adaptation /// d'identité publique à . /// /// Le modèle métier SuiviTemps ne supporte que la création : UPDATE, ARCHIVE et /// DELETE retournent PERMANENT_FAILURE. /// [Collection("ApplicationRuntimeContext")] public class SuiviTempsSyncOperationHandlerTests { private static SyncOperation CreateOperation( SyncOperationType type, Guid? entityGuid = null, string? payload = null) { return new SyncOperation { OperationId = Guid.NewGuid(), DeviceId = "device-1", EntityType = SyncEntityTypes.SuiviTemps, OperationType = type, EntityGuid = entityGuid ?? Guid.NewGuid(), Payload = payload ?? ValidPayload() }; } private static string ValidPayload() => JsonSerializer.Serialize(new SuiviTempsSyncPayload { CollaborateurId = "11111111-1111-1111-1111-111111111111", InterventionId = "22222222-2222-2222-2222-222222222222", RendezVousId = "33333333-3333-3333-3333-333333333333", Date = new DateTime(2026, 8, 14, 9, 0, 0), DureeMinutes = 60, TypeTempsKey = "TYPE-1", Description = "Saisie de temps test" }); private static SuiviTempsSyncOperationHandler CreateHandler( ISuiviTempsService service, IMobileEntityIdentityResolver? resolver = null) => new(service, resolver ?? new MobileEntityIdentityResolver()); [Fact] public async Task Create_ValidPayload_ReturnsSuccessWithCanonicalGuid() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var requestedGuid = Guid.NewGuid(); var physicalGuid = Guid.NewGuid(); var fake = new FakeSuiviTempsService { ExistingById = null, CreatedId = physicalGuid.ToString("D") }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, requestedGuid, ValidPayload())); // Succès, service appelé UNE fois. Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(1, fake.CreateCallCount); Assert.Equal(physicalGuid.ToString("D"), result.CreatedEntityId); // CAS B : le contrat de création ne porte pas de GUID métier, le // repository génère le GUID physique. Requested et Canonical diffèrent. Assert.Equal(requestedGuid, result.RequestedEntityGuid); Assert.Equal(physicalGuid, result.CanonicalEntityGuid); Assert.NotEqual(requestedGuid, result.CanonicalEntityGuid); // Le payload est mappé vers le vrai contrat métier. Assert.Equal("11111111-1111-1111-1111-111111111111", fake.LastCreateRequest?.CollaborateurId); Assert.Equal("22222222-2222-2222-2222-222222222222", fake.LastCreateRequest?.InterventionId); Assert.Equal("33333333-3333-3333-3333-333333333333", fake.LastCreateRequest?.RendezVousId); Assert.Equal(60, fake.LastCreateRequest?.DureeMinutes); Assert.Equal("TYPE-1", fake.LastCreateRequest?.TypeTemps?.Key); Assert.Equal("Saisie de temps test", fake.LastCreateRequest?.Description); } [Fact] public async Task Create_AlreadyExists_IsIdempotent() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var entityGuid = Guid.NewGuid(); var fake = new FakeSuiviTempsService { ExistingById = new SuiviTempsDto { Id = entityGuid.ToString("D") } }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, entityGuid, ValidPayload())); // Déjà créée : SUCCESS sans recréer. Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(0, fake.CreateCallCount); } [Fact] public async Task Create_InvalidPayload_ReturnsPermanentFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeSuiviTempsService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, payload: "not-json")); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("SUIVI_TEMPS_INVALID_PAYLOAD", result.ErrorCode); } [Fact] public async Task Create_EmptyEntityGuid_ReturnsPermanentFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeSuiviTempsService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, Guid.Empty, ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("SUIVI_TEMPS_INVALID_ENTITY_GUID", result.ErrorCode); } [Fact] public async Task Update_ReturnsPermanentFailure_AndDoesNotCreate() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var fake = new FakeSuiviTempsService(); var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("SUIVI_TEMPS_OPERATION_NOT_SUPPORTED", result.ErrorCode); Assert.Equal(0, fake.CreateCallCount); } [Fact] public async Task Archive_ReturnsPermanentFailure_AndDoesNotCreate() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var fake = new FakeSuiviTempsService(); var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Archive, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("SUIVI_TEMPS_OPERATION_NOT_SUPPORTED", result.ErrorCode); Assert.Equal(0, fake.CreateCallCount); } [Fact] public async Task Delete_ReturnsPermanentFailure_AndDoesNotCreate() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var fake = new FakeSuiviTempsService(); var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Delete, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("SUIVI_TEMPS_OPERATION_NOT_SUPPORTED", result.ErrorCode); Assert.Equal(0, fake.CreateCallCount); } [Fact] public void SupportedEntityTypes_ContainsSuiviTemps() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeSuiviTempsService()); Assert.Contains(SyncEntityTypes.SuiviTemps, handler.SupportedEntityTypes); } [Fact] public void Handler_DependsOnlyOnCommonAbstractions() { // Le handler public ne doit dépendre que du service métier commun et du // résolveur d'identité. Aucune dépendance vers un repository physique // Batik/Omika. var ctor = typeof(SuiviTempsSyncOperationHandler) .GetConstructors() .Single(); Assert.Equal(2, ctor.GetParameters().Length); Assert.Equal(typeof(ISuiviTempsService), ctor.GetParameters()[0].ParameterType); Assert.Equal(typeof(IMobileEntityIdentityResolver), ctor.GetParameters()[1].ParameterType); } // --- Payload sérialisation / désérialisation JSON ------------------------- [Fact] public void Payload_RoundTrips_ThroughJson() { var payload = new SuiviTempsSyncPayload { CollaborateurId = "11111111-1111-1111-1111-111111111111", InterventionId = "22222222-2222-2222-2222-222222222222", RendezVousId = "33333333-3333-3333-3333-333333333333", Date = new DateTime(2026, 8, 14, 9, 0, 0), DureeMinutes = 45, TypeTempsKey = "TYPE-2", Description = "Libellé" }; string json = JsonSerializer.Serialize(payload); SuiviTempsSyncPayload? roundTripped = JsonSerializer.Deserialize( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.NotNull(roundTripped); Assert.Equal(payload.CollaborateurId, roundTripped.CollaborateurId); Assert.Equal(payload.InterventionId, roundTripped.InterventionId); Assert.Equal(payload.RendezVousId, roundTripped.RendezVousId); Assert.Equal(payload.Date, roundTripped.Date); Assert.Equal(payload.DureeMinutes, roundTripped.DureeMinutes); Assert.Equal(payload.TypeTempsKey, roundTripped.TypeTempsKey); Assert.Equal(payload.Description, roundTripped.Description); } // --- Registry ------------------------------------------------------------- [Fact] public void Registry_MapsSuiviTempsToSuiviTempsHandler() { var registry = new SyncOperationHandlerRegistry(new ISyncOperationHandler[] { new SuiviTempsSyncOperationHandler(new FakeSuiviTempsService(), new MobileEntityIdentityResolver()) }); ISyncOperationHandler? handler = registry.GetHandler(SyncEntityTypes.SuiviTemps); Assert.NotNull(handler); Assert.IsType(handler); } [Fact] public void Registry_UnknownEntityType_ReturnsNull() { var registry = new SyncOperationHandlerRegistry(new ISyncOperationHandler[] { new SuiviTempsSyncOperationHandler(new FakeSuiviTempsService(), new MobileEntityIdentityResolver()) }); Assert.Null(registry.GetHandler("UnknownType")); } private sealed class FakeSuiviTempsService : ISuiviTempsService { public SuiviTempsDto? ExistingById { get; set; } public string? CreatedId { get; set; } public CreateSuiviTempsRequest? LastCreateRequest { get; private set; } public int CreateCallCount { get; private set; } public Task> SearchAsync( SuiviTempsSearchCriteria criteria, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 10, 0, 0)); public Task GetByIdAsync( string id, CancellationToken cancellationToken = default) => Task.FromResult(ExistingById); public Task CreateAsync( CreateSuiviTempsRequest request, CancellationToken cancellationToken = default) { CreateCallCount++; LastCreateRequest = request; return Task.FromResult(new SuiviTempsDto { Id = CreatedId ?? Guid.NewGuid().ToString("D") }); } } }