248 lines
11 KiB
C#
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));
|
|
}
|
|
}
|