using System.Net; 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.App.Local.Services.PayloadRewriters; 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; /// /// Notes saisies HORS LIGNE sur une photo : enregistrement local immédiat, puis /// synchronisation à la reconnexion — toujours APRÈS le document qu'elles /// référencent, car l'API ne peut pas rattacher une note à un document qu'elle /// ne connaît pas encore. /// public class DocumentNoteSyncTests : 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 int AddNoteCalls { get; private set; } public Guid? LastNoteDocumentGuid { get; private set; } public string? LastNoteContenu { get; private set; } /// GUID canonique renvoyé par l'upload (null : identité inchangée). public Guid? UploadCanonicalGuid { get; set; } /// GUID serveur de la note créée par POST /api/documents/{id}/notes. public Guid CreatedNoteGuid { get; set; } = Guid.NewGuid(); /// Panne réseau simulée sur l'upload du document. public bool ThrowOnUpload { get; set; } /// Refus HTTP définitif simulé sur l'ajout de note. public HttpStatusCode? NoteErrorStatus { get; set; } public Task UploadDocumentAsync( DocumentUploadRequest request, CancellationToken ct = default) { UploadCalls++; if (ThrowOnUpload) { throw new HttpRequestException("Network error"); } return Task.FromResult(new DocumentUploadResponse { Success = true, DocumentGuid = request.DocumentGuid, CanonicalDocumentGuid = UploadCanonicalGuid }); } public Task AddNoteByDocumentAsync( Guid documentGuid, string contenu, CancellationToken ct = default) { AddNoteCalls++; LastNoteDocumentGuid = documentGuid; LastNoteContenu = contenu; if (NoteErrorStatus is { } status) { throw new HttpRequestException("Refus serveur", inner: null, statusCode: status); } return Task.FromResult(new NoteInterneDto { Id = CreatedNoteGuid.ToString("D"), Contenu = contenu, DateCreation = new DateTime(2026, 9, 10, 12, 0, 0) }); } public Task DownloadDocumentAsync( Guid documentGuid, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetNotesByDocumentAsync( Guid documentGuid, CancellationToken ct = default) => throw new NotSupportedException(); public Task SendOperationAsync( SyncOperation operation, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetStatusAsync(Guid operationId, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetSnapshotAsync(CancellationToken ct = default) => Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow }); public string? AccessToken { get; set; } public Task ActivateAsync( Omika.Core.Contracts.Mobile.License.LicenseActivateRequest request, CancellationToken ct = default) => throw new NotSupportedException(); public Task ValidateAsync( Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetDevelopmentSessionAsync( string deviceId, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetBootstrapAsync( CancellationToken ct = default) => throw new NotSupportedException(); } private sealed class FakeFileLauncher : IFileLauncher { public Task OpenAsync(string localPath) => Task.FromResult(true); } private sealed class FakeFilePicker : IFilePicker { public Task PickPhotoAsync() => Task.FromResult(null); public Task PickDocumentAsync() => Task.FromResult(null); public Task> PickPhotosAsync() => Task.FromResult>([]); } private sealed class FakePhotoCapture : IPhotoCapture { public Task CapturePhotoAsync(CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeDeviceIdProvider : IDeviceIdProvider { public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult("device-test-1"); } private readonly FakeNetwork _network = new(); private readonly FakeApiClient _api = new(); private readonly MobileIdentityReconciliationService _reconciliation; private readonly SnapshotApplier _snapshotApplier; private readonly SyncEngine _engine; private readonly DocumentLocalService _service; private readonly string _filesDirectory; public DocumentNoteSyncTests() { _filesDirectory = Path.Combine(Path.GetTempPath(), "omika-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_filesDirectory); _reconciliation = new MobileIdentityReconciliationService( Database, IdentityMappingRepository, OutboxRepository, [new NoteDocumentPayloadRewriter()]); _snapshotApplier = new SnapshotApplier(Database); _engine = new SyncEngine( _network, _api, OutboxRepository, _reconciliation, _snapshotApplier, new FakeDeviceIdProvider(), NullLogger.Instance); _service = new DocumentLocalService( DocumentMetadataRepository, NoteDocumentRepository, DocumentCacheRepository, OutboxRepository, _api, new FakeFileLauncher(), new FakeFilePicker(), new FakePhotoCapture(), new FakeImageOptimizer(), new FakePhotoPolicyProvider(), new FakeDocumentDateResolver(), _network, _reconciliation, _filesDirectory, NullLogger.Instance); } ~DocumentNoteSyncTests() { try { if (Directory.Exists(_filesDirectory)) { Directory.Delete(_filesDirectory, recursive: true); } } catch { // Nettoyage best-effort. } } /// /// Enfile une opération Outbox de création de document, avec un fichier local /// réel (l'upload lit le fichier avant l'envoi). /// private async Task EnqueueDocumentCreateAsync(Guid documentGuid, Guid interventionGuid) { string localPath = Path.Combine(_filesDirectory, $"{documentGuid:N}.jpg"); await File.WriteAllBytesAsync(localPath, [1, 2, 3, 4]); var payload = new DocumentOutboxPayload { DocumentGuid = documentGuid, InterventionGuid = interventionGuid, NomFichier = "photo.jpg", MimeType = "image/jpeg", Extension = ".jpg", Categorie = "T.DOC.PHOTO", LocalPath = localPath, DateCreation = new DateTime(2026, 9, 10, 14, 0, 0) }; await OutboxRepository.AddAsync(new OutboxOperation { OperationId = Guid.NewGuid(), EntityType = LocalSyncEntityTypes.Document, OperationType = SyncOperationType.Create, RequestedEntityGuid = documentGuid, Payload = JsonSerializer.Serialize(payload), State = OutboxState.Pending }); } private async Task GetNoteOperationAsync() { IReadOnlyList all = await OutboxRepository.GetPendingOperationsAsync(); return all.SingleOrDefault(o => o.EntityType == LocalSyncEntityTypes.NoteDocument) ?? (await OutboxRepository.GetRejectedAsync()) .SingleOrDefault(o => o.EntityType == LocalSyncEntityTypes.NoteDocument); } // ===================== ORDRE DOCUMENTS → NOTES ===================== /// /// Contrainte centrale : la note ne part JAMAIS avant son document. Tant que /// l'envoi du document échoue (panne réseau), la note reste en attente sur /// l'appareil et aucun appel de note n'est émis. /// [Fact] public async Task NoteDifferee_TantQueLeDocumentNestPasPasse() { // Arrange : document hors ligne en échec réseau + note saisie dans la foulée. Guid documentGuid = Guid.NewGuid(); await EnqueueDocumentCreateAsync(documentGuid, Guid.NewGuid()); await _service.AddNoteAsync(documentGuid, "Note de terrain"); _api.ThrowOnUpload = true; // Act : premier cycle (le document ne passe pas). await _engine.PushAsync(); // Assert : aucune note envoyée, l'opération de note est toujours en attente. Assert.Equal(0, _api.AddNoteCalls); OutboxOperation? noteOp = await GetNoteOperationAsync(); Assert.NotNull(noteOp); Assert.Equal(OutboxState.Pending, noteOp.State); // Act : deuxième cycle, réseau revenu. _api.ThrowOnUpload = false; await _engine.PushAsync(); // Assert : document puis note, dans cet ordre. Assert.Equal(2, _api.UploadCalls); Assert.Equal(1, _api.AddNoteCalls); OutboxOperation? envoyee = await OutboxRepository.GetByIdAsync(noteOp.OperationId); Assert.NotNull(envoyee); Assert.Equal(OutboxState.Succeeded, envoyee.State); } /// /// Note sur un document DÉJÀ synchronisé (aucune opération document en cours) : /// rien ne la retarde, elle part au premier cycle. /// [Fact] public async Task NoteSurDocumentDejaSynchronise_PartAuPremierCycle() { // Arrange Guid documentGuid = Guid.NewGuid(); await _service.AddNoteAsync(documentGuid, "Note sur photo existante"); // Act await _engine.PushAsync(); // Assert Assert.Equal(1, _api.AddNoteCalls); Assert.Equal(documentGuid, _api.LastNoteDocumentGuid); Assert.Equal("Note sur photo existante", _api.LastNoteContenu); } // ===================== IDENTITÉS ===================== /// /// Le document créé hors ligne porte une identité mobile (A) réconciliée en /// identité serveur (B) par l'upload. La note doit viser B — sinon le serveur /// ne peut pas la rattacher — et suivre la même réconciliation localement. /// [Fact] public async Task Note_ViseLeDocumentCanoniqueEtSuitLaReconciliation() { // Arrange Guid identiteMobile = Guid.NewGuid(); Guid identiteServeur = Guid.NewGuid(); Guid noteServeur = Guid.NewGuid(); _api.UploadCanonicalGuid = identiteServeur; _api.CreatedNoteGuid = noteServeur; await EnqueueDocumentCreateAsync(identiteMobile, Guid.NewGuid()); await _service.AddNoteAsync(identiteMobile, "Note rattachée au bon document"); // Act await _engine.PushAsync(); // Assert : la note est envoyée sur le GUID canonique du document… Assert.Equal(1, _api.AddNoteCalls); Assert.Equal(identiteServeur, _api.LastNoteDocumentGuid); // …et l'identité locale de la note a suivi celle renvoyée par le serveur. IReadOnlyList notes = await NoteDocumentRepository.GetByDocumentAsync(identiteServeur); Assert.Equal(noteServeur, Assert.Single(notes).Id); } /// /// Le réécrivain de payload remplace l'identité mobile du document par son /// identité canonique dans l'opération de note, de façon typée. /// [Fact] public void ReecrivainNoteDocument_RemplaceLIdentiteDuDocument() { // Arrange var rewriter = new NoteDocumentPayloadRewriter(); Guid ancien = Guid.NewGuid(); Guid nouveau = Guid.NewGuid(); string payload = JsonSerializer.Serialize(new NoteDocumentSyncPayload { DocumentId = ancien.ToString("D"), Contenu = "Note" }); // Act string reecrit = rewriter.Rewrite(payload, ancien, nouveau); // Assert NoteDocumentSyncPayload? relu = JsonSerializer.Deserialize(reecrit); Assert.NotNull(relu); Assert.Equal(nouveau.ToString("D"), relu.DocumentId); Assert.Equal("Note", relu.Contenu); } // ===================== REFUS ===================== /// /// Un refus définitif du serveur (document absent, droit manquant) ne boucle /// pas indéfiniment : l'opération passe en « Rejeté » (visible dans la file) /// et la note saisie reste sur l'appareil, signalée comme non synchronisée. /// [Fact] public async Task Note_RefusDefinitif_RejeteeEtNoteLocaleConservee() { // Arrange : document déjà synchronisé, refus 404 sur l'ajout de note. Guid documentGuid = Guid.NewGuid(); await _service.AddNoteAsync(documentGuid, "Note refusée"); _api.NoteErrorStatus = HttpStatusCode.NotFound; // Act await _engine.PushAsync(); // Assert : rejetée, avec une raison lisible. OutboxOperation? noteOp = await GetNoteOperationAsync(); Assert.NotNull(noteOp); Assert.Equal(OutboxState.Rejected, noteOp.State); Assert.Contains("absent", noteOp.LastError, StringComparison.OrdinalIgnoreCase); // La saisie reste consultable, marquée comme non synchronisée. IReadOnlyList notes = await _service.GetNotesForDocumentAsync(documentGuid); DocumentNoteItem note = Assert.Single(notes); Assert.Equal("Note refusée", note.Contenu); Assert.NotNull(note.SyncLabel); } }