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