using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Infrastructure;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.Documents;
using Omika.Core.Contracts.Mobile.Snapshot;
using Omika.Core.Contracts.Mobile.Sync;
using LocalSyncEntityTypes = Omika.App.Local.Infrastructure.SyncEntityTypes;
namespace Omika.Tests.Mobile.Local;
///
/// Tests de synchronisation des Documents via le SyncEngine : upload du fichier
/// local, conservation de OperationId/DocumentGuid, fichier manquant, offline et
/// rejet serveur.
///
public class DocumentSyncTests : LocalTestBase
{
private sealed class FakeNetwork : INetworkConnectivity
{
public bool Online { get; set; } = true;
public Task IsOnlineAsync(CancellationToken ct = default) => Task.FromResult(Online);
}
private sealed class FakeApiClient : IMobileApiClient
{
public int UploadCalls { get; private set; }
public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true };
public DocumentUploadRequest? LastUploadRequest { get; private set; }
public Task SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetStatusAsync(Guid operationId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetSnapshotAsync(CancellationToken ct = default)
=> Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
UploadCalls++;
LastUploadRequest = request;
return Task.FromResult(UploadToReturn);
}
public string? AccessToken { get; set; }
public Task ActivateAsync(
Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task ValidateAsync(
Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetBootstrapAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
}
private readonly FakeNetwork _network = new();
private readonly FakeApiClient _api = new();
private readonly MobileIdentityReconciliationService _reconciliation;
private readonly SnapshotApplier _snapshotApplier;
private readonly SyncEngine _engine;
public DocumentSyncTests()
{
_reconciliation = new MobileIdentityReconciliationService(
Database, IdentityMappingRepository, OutboxRepository, []);
_snapshotApplier = new SnapshotApplier(Database);
_engine = new SyncEngine(
_network, _api, OutboxRepository, _reconciliation, _snapshotApplier,
new FakeDeviceIdProvider(), NullLogger.Instance);
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public Task GetDeviceIdAsync(CancellationToken ct = default)
=> Task.FromResult("device-test-1");
}
private static string CreateTempFile(string directory, string name, byte[] content)
{
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, name);
File.WriteAllBytes(path, content);
return path;
}
private static OutboxOperation CreateDocumentOp(Guid documentGuid, string localPath)
=> CreateDocumentOp(documentGuid, localPath, dateCreation: null);
private static OutboxOperation CreateDocumentOp(
Guid documentGuid,
string localPath,
DateTime? dateCreation)
{
var payload = new DocumentOutboxPayload
{
DocumentGuid = documentGuid,
InterventionGuid = Guid.NewGuid(),
NomFichier = "doc.pdf",
MimeType = "application/pdf",
Extension = ".pdf",
Categorie = "Document",
LocalPath = localPath,
DateCreation = dateCreation
};
return new OutboxOperation
{
OperationId = Guid.NewGuid(),
EntityType = LocalSyncEntityTypes.Document,
OperationType = SyncOperationType.Create,
RequestedEntityGuid = documentGuid,
Payload = JsonSerializer.Serialize(payload),
State = OutboxState.Pending
};
}
[Fact]
public async Task DocumentPending_TransmetLaDateReelleDUnePhoto()
{
// Arrange : la date métier (date réelle du fichier) doit traverser la
// synchronisation jusqu'au serveur. Sans cela, le serveur horodaterait le
// document à l'instant de l'upload et le rafraîchissement à la demande
// écraserait ensuite cette valeur sur l'appareil.
var priseDeVue = new DateTime(2025, 6, 23, 16, 42, 0);
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "photo.jpg", [1, 2, 3, 4]);
Guid documentGuid = Guid.NewGuid();
await OutboxRepository.AddAsync(CreateDocumentOp(documentGuid, localPath, priseDeVue));
// Act
await _engine.SyncAsync();
// Assert
Assert.NotNull(_api.LastUploadRequest);
Assert.Equal(priseDeVue, _api.LastUploadRequest!.DateCreation);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task DocumentPending_OperationHistoriqueSansDate_TransmetNull()
{
// Arrange : opération Outbox créée avant la fonctionnalité : aucune date,
// le serveur applique alors sa date courante (comportement d'origine).
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3, 4]);
Guid documentGuid = Guid.NewGuid();
await OutboxRepository.AddAsync(CreateDocumentOp(documentGuid, localPath, dateCreation: null));
// Act
await _engine.SyncAsync();
// Assert
Assert.NotNull(_api.LastUploadRequest);
Assert.Null(_api.LastUploadRequest!.DateCreation);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task DocumentPending_LitFichierLocal_EtUpload()
{
// Arrange
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3, 4]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
// Act
SyncCycleResult result = await _engine.SyncAsync();
// Assert : upload effectué avec le contenu du fichier local.
Assert.Equal(1, _api.UploadCalls);
Assert.NotNull(_api.LastUploadRequest);
Assert.Equal(documentGuid, _api.LastUploadRequest.DocumentGuid);
Assert.Equal([1, 2, 3, 4], _api.LastUploadRequest.Contenu);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task OperationIdConserveAuRetry()
{
// Arrange
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
Guid operationId = op.OperationId;
// Act : premier cycle (succès), puis relecture.
await _engine.SyncAsync();
// Assert : OperationId conservé (pas de nouveau GUID).
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(operationId);
Assert.NotNull(loaded);
Assert.Equal(operationId, loaded.OperationId);
Assert.Equal(documentGuid, loaded.RequestedEntityGuid);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task DocumentGuidConserve()
{
// Arrange
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
// Act
await _engine.SyncAsync();
// Assert : DocumentGuid conservé dans la requête d'upload.
Assert.NotNull(_api.LastUploadRequest);
Assert.Equal(documentGuid, _api.LastUploadRequest.DocumentGuid);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task FichierManquant_AucunFauxSuccess()
{
// Arrange : opération Document dont le fichier local a disparu.
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
// Supprime le fichier local avant l'upload.
File.Delete(localPath);
// Act
SyncCycleResult result = await _engine.SyncAsync();
// Assert : aucun upload, opération en Rejected local, pas de SUCCESS.
Assert.Equal(0, _api.UploadCalls);
Assert.Equal(0, result.OperationsSucceeded);
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
Assert.NotNull(loaded);
Assert.Equal(OutboxState.Rejected, loaded.State);
Assert.Contains("introuvable", loaded.LastError, StringComparison.OrdinalIgnoreCase);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task Offline_OperationConservee()
{
// Arrange
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
_network.Online = false;
// Act
SyncCycleResult result = await _engine.SyncAsync();
// Assert : aucun upload, opération conservée en Pending.
Assert.Equal(0, _api.UploadCalls);
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
Assert.NotNull(loaded);
Assert.Equal(OutboxState.Pending, loaded.State);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
[Fact]
public async Task ServeurRejected_EtatConserve()
{
// Arrange
string dir = Path.Combine(Path.GetTempPath(), "omika-sync", Guid.NewGuid().ToString("N"));
string localPath = CreateTempFile(dir, "doc.pdf", [1, 2, 3]);
Guid documentGuid = Guid.NewGuid();
OutboxOperation op = CreateDocumentOp(documentGuid, localPath);
await OutboxRepository.AddAsync(op);
_api.UploadToReturn = new DocumentUploadResponse
{
Success = false,
ErrorCode = "ERR001",
ErrorMessage = "Rejeté par le serveur"
};
// Act
SyncCycleResult result = await _engine.SyncAsync();
// Assert : opération en Rejected, erreur conservée.
Assert.Equal(1, _api.UploadCalls);
Assert.Equal(0, result.OperationsSucceeded);
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(op.OperationId);
Assert.NotNull(loaded);
Assert.Equal(OutboxState.Rejected, loaded.State);
Assert.Contains("ERR001", loaded.LastError);
// Nettoyage.
Directory.Delete(dir, recursive: true);
}
}