using System.Text.Json; using Omika.Core.Compatibility; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Runtime; using Omika.Core.Services.Mobile; using Omika.Tests.Runtime; using Omika.Worker; using Xunit; using Omika.Core.Domain; namespace Omika.Tests.Mobile; /// /// Tests du handler d'opération de synchronisation Intervention. /// Le handler est COMMUN (aucune condition Batik/Omika) : il délègue le /// traitement physique à et l'adaptation /// d'identité publique à . /// [Collection("ApplicationRuntimeContext")] public class InterventionSyncOperationHandlerTests { private static SyncOperation CreateOperation( SyncOperationType type, Guid? entityGuid = null, string? payload = null) { return new SyncOperation { OperationId = Guid.NewGuid(), DeviceId = "device-1", EntityType = SyncEntityTypes.Intervention, OperationType = type, EntityGuid = entityGuid ?? Guid.NewGuid(), Payload = payload ?? JsonSerializer.Serialize(new InterventionSyncPayload { Titre = "Intervention test", Reference = "REF-001", TypeKey = "TYPE-1" }) }; } private static string ValidPayload() => JsonSerializer.Serialize(new InterventionSyncPayload { Titre = "Intervention test", Reference = "REF-001", TypeKey = "TYPE-1" }); private static InterventionSyncOperationHandler CreateHandler( IInterventionService service, IMobileEntityIdentityResolver? resolver = null) => new(service, resolver ?? new MobileEntityIdentityResolver()); [Fact] public async Task Create_ValidPayload_ReturnsSuccessWithCreatedEntityId() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var entityGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = null, CreatedId = entityGuid.ToString("D") }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, entityGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(entityGuid.ToString("D"), result.CreatedEntityId); // Le GUID métier mobile est transmis au backend. Assert.Equal(entityGuid, fake.LastCreateRequest?.Guid); Assert.Equal("REF-001", fake.LastCreateRequest?.Reference); } [Fact] public async Task Create_AlreadyExists_IsIdempotent() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var entityGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = new InterventionDetailsDto { 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.Null(fake.LastCreateRequest); } [Fact] public async Task Create_InvalidPayload_ReturnsPermanentFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, payload: "not-json")); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("INTERVENTION_INVALID_PAYLOAD", result.ErrorCode); } [Fact] public async Task Create_EmptyEntityGuid_ReturnsPermanentFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, Guid.Empty, ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("INTERVENTION_INVALID_ENTITY_GUID", result.ErrorCode); } [Fact] public async Task Update_Existing_ReturnsSuccess() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var entityGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = new InterventionDetailsDto { Id = entityGuid.ToString("D") }, UpdatedId = entityGuid.ToString("D") }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, entityGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(entityGuid.ToString("D"), fake.LastUpdateId); } [Fact] public async Task Update_NotFound_ReturnsTransientFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.TransientFailure, result.Kind); Assert.Equal("INTERVENTION_NOT_FOUND", result.ErrorCode); } [Fact] public async Task Archive_Existing_ReturnsSuccess() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var entityGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = new InterventionDetailsDto { Id = entityGuid.ToString("D") }, ArchiveResult = true }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Archive, entityGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(entityGuid.ToString("D"), fake.LastArchiveId); } [Fact] public async Task Archive_NotFound_ReturnsTransientFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Archive, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.TransientFailure, result.Kind); } [Fact] public async Task Delete_ReturnsPermanentFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Delete, payload: ValidPayload())); Assert.Equal(SyncOperationResultKind.PermanentFailure, result.Kind); Assert.Equal("INTERVENTION_OPERATION_NOT_SUPPORTED", result.ErrorCode); } [Fact] public void SupportedEntityTypes_ContainsIntervention() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var handler = CreateHandler(new FakeInterventionService()); Assert.Contains(SyncEntityTypes.Intervention, 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(InterventionSyncOperationHandler) .GetConstructors() .Single(); Assert.Equal(2, ctor.GetParameters().Length); Assert.Equal(typeof(IInterventionService), ctor.GetParameters()[0].ParameterType); Assert.Equal(typeof(IMobileEntityIdentityResolver), ctor.GetParameters()[1].ParameterType); } // --- Identité publique Batik (GUID synthétique réversible) ---------------- [Fact] public async Task Create_Batik_ReturnsCanonicalSyntheticGuid() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); var mobileGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = null, CreatedId = "42", CreatedSourceReference = new LegacyEntityReference( DatabaseSchemaVersion.Batik, NumericId: 42) }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Create, mobileGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); // Le GUID canonique retourné est un GUID synthétique réversible, distinct // du GUID mobile temporaire. Assert.NotNull(result.CanonicalEntityGuid); Assert.NotEqual(mobileGuid, result.CanonicalEntityGuid); Assert.Equal(mobileGuid, result.RequestedEntityGuid); } [Fact] public async Task Update_Batik_WithCanonicalGuid_ResolvesToNumericId() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); var resolver = new MobileEntityIdentityResolver(); var canonicalGuid = resolver.CreateCanonicalIdentity( SyncEntityTypes.Intervention, new PhysicalEntityIdentity { NumericId = 42 }); var fake = new FakeInterventionService { ExistingById = new InterventionDetailsDto { Id = "42" }, UpdatedId = "42" }; var handler = CreateHandler(fake, resolver); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, canonicalGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); // Le PK numérique Batik est résolu depuis le GUID synthétique, jamais // exposé au mobile. Assert.Equal("42", fake.LastUpdateId); } [Fact] public async Task Update_Batik_WithTemporaryMobileGuid_ReturnsTransientFailure() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); // Un GUID mobile temporaire non canonique n'est pas résolvable sans // stockage durable : le mobile doit utiliser le GUID canonique. var mobileGuid = Guid.NewGuid(); var handler = CreateHandler(new FakeInterventionService()); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, mobileGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.TransientFailure, result.Kind); Assert.Equal("INTERVENTION_NOT_FOUND", result.ErrorCode); } // --- Identité publique Omika (GUID physique direct) ----------------------- [Fact] public async Task Update_Omika_ResolvesByPhysicalGuid() { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika); var physicalGuid = Guid.NewGuid(); var fake = new FakeInterventionService { ExistingById = new InterventionDetailsDto { Id = physicalGuid.ToString("D") }, UpdatedId = physicalGuid.ToString("D") }; var handler = CreateHandler(fake); SyncOperationResult result = await handler.ApplyAsync( CreateOperation(SyncOperationType.Update, physicalGuid, ValidPayload())); Assert.Equal(SyncOperationResultKind.Success, result.Kind); Assert.Equal(physicalGuid.ToString("D"), fake.LastUpdateId); } private sealed class FakeInterventionService : IInterventionService { public InterventionDetailsDto? ExistingById { get; set; } public string? CreatedId { get; set; } public LegacyEntityReference? CreatedSourceReference { get; set; } public string? UpdatedId { get; set; } public bool ArchiveResult { get; set; } public CreateInterventionRequest? LastCreateRequest { get; private set; } public string? LastUpdateId { get; private set; } public string? LastArchiveId { get; private set; } public Task> SearchAsync( SearchInterventionsRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 10, 0, 0)); public Task> SearchDetailsAsync( SearchInterventionsRequest request, 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( CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) { LastCreateRequest = request; return Task.FromResult(new InterventionDetailsDto { Id = CreatedId ?? Guid.NewGuid().ToString("D"), SourceReference = CreatedSourceReference }); } public Task UpdateAsync( string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) { LastUpdateId = id; return Task.FromResult(UpdatedId is null ? null : new InterventionDetailsDto { Id = UpdatedId }); } public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) { LastArchiveId = id; return Task.FromResult(ArchiveResult); } public Task> GetNotesAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync( string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new NoteInterneDto()); public Task> GetEmailsAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddEmailAsync( string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new EmailInterventionDto()); } }