Files
Omika/tests/Omika.Tests/Mobile/Local/DocumentOpenAfterSyncTests.cs
2026-10-23 18:46:50 +02:00

248 lines
11 KiB
C#

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.Documents;
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;
/// <summary>
/// Repro du bug « document ajouté sur cet appareil → synchronisé → impossible à
/// ouvrir ». Vérifie que la référence locale durable du document survit à la
/// synchronisation (réconciliation d'identité + snapshot + rafraîchissement).
/// </summary>
public class DocumentOpenAfterSyncTests : LocalTestBase
{
private sealed class FakeNetwork : INetworkConnectivity
{
public bool Online { get; set; } = true;
public Task<bool> IsOnlineAsync(CancellationToken ct = default) => Task.FromResult(Online);
}
private sealed class FakeApiClient : IMobileApiClient
{
public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true };
public SyncStatusResponse StatusToReturn { get; set; } = new() { Status = SyncStatus.Success };
public IReadOnlyList<DocumentSummaryDto> DocumentsToReturn { get; set; } = [];
public DocumentDownloadResponse? DownloadToReturn { get; set; }
public DocumentUploadRequest? LastUploadRequest { get; private set; }
public Task<SyncOperationReceipt> SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<SyncStatusResponse> GetStatusAsync(Guid operationId, CancellationToken ct = default)
=> Task.FromResult(StatusToReturn);
public Task<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
=> Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
public Task<DocumentDownloadResponse> DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(DownloadToReturn ?? new DocumentDownloadResponse
{
DocumentGuid = documentGuid,
NomFichier = "doc.pdf",
MimeType = "application/pdf",
TailleOctets = 3,
Contenu = [1, 2, 3],
Existe = true
});
public Task<DocumentUploadResponse> UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
LastUploadRequest = request;
return Task.FromResult(UploadToReturn);
}
public Task<IReadOnlyList<DocumentSummaryDto>> GetDocumentsByInterventionAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(DocumentsToReturn);
public string? AccessToken { get; set; }
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> ActivateAsync(
Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> ValidateAsync(
Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<Omika.Core.Contracts.Mobile.Bootstrap.MobileBootstrapResponse> GetBootstrapAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class FakeFilePicker : IFilePicker
{
public PickedFile? DocumentToReturn { get; set; }
public Task<PickedFile?> PickPhotoAsync() => Task.FromResult<PickedFile?>(null);
public Task<PickedFile?> PickDocumentAsync() => Task.FromResult(DocumentToReturn);
public Task<IReadOnlyList<PickedFile>> PickPhotosAsync() => Task.FromResult<IReadOnlyList<PickedFile>>([]);
}
private sealed class FakePhotoCapture : IPhotoCapture
{
public Task<PickedFile?> CapturePhotoAsync(CancellationToken ct = default) => Task.FromResult<PickedFile?>(null);
}
private sealed class FakeFileLauncher : IFileLauncher
{
public string? LastOpenedPath { get; private set; }
public Task<bool> OpenAsync(string localPath) { LastOpenedPath = localPath; return Task.FromResult(true); }
}
private sealed class FakeDateResolver : IDocumentDateResolver
{
public Task<DocumentDateResolution> ResolveAsync(string filePath, string? fileName, CancellationToken ct = default)
=> Task.FromResult(new DocumentDateResolution(new DateTime(2024, 5, 6, 7, 8, 9), DocumentDateSource.SystemFallback));
}
private readonly FakeNetwork _network = new();
private readonly FakeApiClient _api = new();
private readonly FakeFilePicker _picker = new();
private readonly MobileIdentityReconciliationService _reconciliation;
private readonly SnapshotApplier _snapshotApplier;
private readonly SyncEngine _engine;
private readonly DocumentLocalService _service;
private readonly string _filesDirectory;
public DocumentOpenAfterSyncTests()
{
_filesDirectory = Path.Combine(Path.GetTempPath(), "omika-tests", Guid.NewGuid().ToString("N"));
_reconciliation = new MobileIdentityReconciliationService(
Database, IdentityMappingRepository, OutboxRepository, []);
_snapshotApplier = new SnapshotApplier(Database);
_engine = new SyncEngine(
_network,
_api,
OutboxRepository,
_reconciliation,
_snapshotApplier,
new FakeDeviceIdProvider(),
NullLogger<SyncEngine>.Instance);
_service = new DocumentLocalService(
DocumentMetadataRepository,
NoteDocumentRepository,
DocumentCacheRepository,
OutboxRepository,
_api,
new FakeFileLauncher(),
_picker,
new FakePhotoCapture(),
new FakeImageOptimizer(),
new FakePhotoPolicyProvider(),
new FakeDateResolver(),
_network,
_reconciliation,
_filesDirectory,
NullLogger<DocumentLocalService>.Instance);
}
~DocumentOpenAfterSyncTests()
{
if (Directory.Exists(_filesDirectory))
{
Directory.Delete(_filesDirectory, recursive: true);
}
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public Task<string> GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult("device-test");
}
private static DocumentSummaryDto CreateServerDocument(Guid id, Guid interventionId)
=> new()
{
Id = id.ToString(),
Nom = "doc.pdf",
Titre = "doc.pdf",
Categorie = "Document",
MimeType = "application/pdf",
Extension = ".pdf",
TailleOctets = 3,
DateCreation = new DateTime(2024, 5, 6, 7, 8, 9),
InterventionId = interventionId.ToString()
};
[Fact]
public async Task DocumentAjoute_Synchronise_AvecGuidCanonique_ResteOuvrable()
{
// Arrange : ajout local d'un document (fichier durable + cache + outbox).
Guid interventionId = Guid.NewGuid();
string source = Path.Combine(_filesDirectory, "source.pdf");
Directory.CreateDirectory(_filesDirectory);
await File.WriteAllBytesAsync(source, [1, 2, 3]);
_picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "doc.pdf", MimeType = "application/pdf" };
InterventionDocumentItem? added = await _service.AddDocumentAsync(interventionId);
Assert.NotNull(added);
Guid mobileGuid = added!.DocumentGuid;
// Le serveur renvoie un GUID canonique DIFFÉRENT du GUID mobile.
Guid canonicalGuid = Guid.NewGuid();
_api.UploadToReturn = new DocumentUploadResponse { Success = true, CanonicalDocumentGuid = canonicalGuid };
_api.StatusToReturn = new SyncStatusResponse { Status = SyncStatus.Success, CanonicalEntityGuid = canonicalGuid };
_api.DocumentsToReturn = [CreateServerDocument(canonicalGuid, interventionId)];
// Le fichier durable existe AVANT la synchronisation.
DocumentCacheEntry? cacheBefore = await DocumentCacheRepository.GetAsync(mobileGuid);
Assert.NotNull(cacheBefore);
Assert.True(File.Exists(cacheBefore!.LocalPath));
// Act : synchronisation complète.
await _engine.SyncAsync();
// Rechargement de la liste (comme le fait le ViewModel après la sync).
IReadOnlyList<InterventionDocumentItem> items = await _service.GetDocumentsForInterventionAsync(interventionId);
// Assert : le document est toujours référencé et son fichier local trouvé.
InterventionDocumentItem item = Assert.Single(items);
Assert.Equal(canonicalGuid, item.DocumentGuid);
string? cachedPath = await _service.GetCachedFilePathAsync(canonicalGuid);
Assert.NotNull(cachedPath);
Assert.True(File.Exists(cachedPath));
}
[Fact]
public async Task DocumentAjoute_Synchronise_SansGuidCanonique_ResteOuvrable()
{
// Arrange : le serveur ne renvoie PAS de GUID canonique (CanonicalDocumentGuid
// null) → pas de réconciliation. Le DocumentGuid mobile est conservé.
Guid interventionId = Guid.NewGuid();
string source = Path.Combine(_filesDirectory, "source.pdf");
Directory.CreateDirectory(_filesDirectory);
await File.WriteAllBytesAsync(source, [1, 2, 3]);
_picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "doc.pdf", MimeType = "application/pdf" };
InterventionDocumentItem? added = await _service.AddDocumentAsync(interventionId);
Assert.NotNull(added);
Guid mobileGuid = added!.DocumentGuid;
_api.UploadToReturn = new DocumentUploadResponse { Success = true, CanonicalDocumentGuid = null };
_api.StatusToReturn = new SyncStatusResponse { Status = SyncStatus.Success };
_api.DocumentsToReturn = [CreateServerDocument(mobileGuid, interventionId)];
await _engine.SyncAsync();
IReadOnlyList<InterventionDocumentItem> items = await _service.GetDocumentsForInterventionAsync(interventionId);
InterventionDocumentItem item = Assert.Single(items);
string? cachedPath = await _service.GetCachedFilePathAsync(item.DocumentGuid);
Assert.NotNull(cachedPath);
Assert.True(File.Exists(cachedPath));
}
}