Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du handler d'opération de synchronisation Intervention.
|
||||
/// Le handler est COMMUN (aucune condition Batik/Omika) : il délègue le
|
||||
/// traitement physique à <see cref="IInterventionService"/> et l'adaptation
|
||||
/// d'identité publique à <see cref="IMobileEntityIdentityResolver"/>.
|
||||
/// </summary>
|
||||
[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<PagedResult<InterventionSummaryDto>> SearchAsync(
|
||||
SearchInterventionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<InterventionSummaryDto>([], 1, 10, 0, 0));
|
||||
|
||||
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
|
||||
SearchInterventionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<InterventionDetailsDto>([], 1, 10, 0, 0));
|
||||
|
||||
public Task<InterventionDetailsDto?> GetByIdAsync(
|
||||
string id,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ExistingById);
|
||||
|
||||
|
||||
public Task<InterventionDetailsDto> 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<InterventionDetailsDto?> 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<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastArchiveId = id;
|
||||
return Task.FromResult(ArchiveResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
|
||||
string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
||||
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
||||
|
||||
public Task<NoteInterneDto> AddNoteAsync(
|
||||
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new NoteInterneDto());
|
||||
|
||||
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
|
||||
string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
|
||||
|
||||
public Task<EmailInterventionDto> AddEmailAsync(
|
||||
string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new EmailInterventionDto());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user