Files
2026-10-23 18:46:50 +02:00

388 lines
15 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class DocumentNoteSyncTests : 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 int UploadCalls { get; private set; }
public int AddNoteCalls { get; private set; }
public Guid? LastNoteDocumentGuid { get; private set; }
public string? LastNoteContenu { get; private set; }
/// <summary>GUID canonique renvoyé par l'upload (null : identité inchangée).</summary>
public Guid? UploadCanonicalGuid { get; set; }
/// <summary>GUID serveur de la note créée par <c>POST /api/documents/{id}/notes</c>.</summary>
public Guid CreatedNoteGuid { get; set; } = Guid.NewGuid();
/// <summary>Panne réseau simulée sur l'upload du document.</summary>
public bool ThrowOnUpload { get; set; }
/// <summary>Refus HTTP définitif simulé sur l'ajout de note.</summary>
public HttpStatusCode? NoteErrorStatus { get; set; }
public Task<DocumentUploadResponse> 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<NoteInterneDto> 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<DocumentDownloadResponse> DownloadDocumentAsync(
Guid documentGuid, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<NoteInterneDto>> GetNotesByDocumentAsync(
Guid documentGuid, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<SyncOperationReceipt> SendOperationAsync(
SyncOperation operation, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<SyncStatusResponse> GetStatusAsync(Guid operationId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
=> Task.FromResult(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
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 FakeFileLauncher : IFileLauncher
{
public Task<bool> OpenAsync(string localPath) => Task.FromResult(true);
}
private sealed class FakeFilePicker : IFilePicker
{
public Task<PickedFile?> PickPhotoAsync() => Task.FromResult<PickedFile?>(null);
public Task<PickedFile?> PickDocumentAsync() => Task.FromResult<PickedFile?>(null);
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 FakeDeviceIdProvider : IDeviceIdProvider
{
public Task<string> 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<SyncEngine>.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<DocumentLocalService>.Instance);
}
~DocumentNoteSyncTests()
{
try
{
if (Directory.Exists(_filesDirectory))
{
Directory.Delete(_filesDirectory, recursive: true);
}
}
catch
{
// Nettoyage best-effort.
}
}
/// <summary>
/// Enfile une opération Outbox de création de document, avec un fichier local
/// réel (l'upload lit le fichier avant l'envoi).
/// </summary>
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<OutboxOperation?> GetNoteOperationAsync()
{
IReadOnlyList<OutboxOperation> all = await OutboxRepository.GetPendingOperationsAsync();
return all.SingleOrDefault(o => o.EntityType == LocalSyncEntityTypes.NoteDocument)
?? (await OutboxRepository.GetRejectedAsync())
.SingleOrDefault(o => o.EntityType == LocalSyncEntityTypes.NoteDocument);
}
// ===================== ORDRE DOCUMENTS → NOTES =====================
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Note sur un document DÉJÀ synchronisé (aucune opération document en cours) :
/// rien ne la retarde, elle part au premier cycle.
/// </summary>
[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 =====================
/// <summary>
/// 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.
/// </summary>
[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<LocalNoteDocument> notes =
await NoteDocumentRepository.GetByDocumentAsync(identiteServeur);
Assert.Equal(noteServeur, Assert.Single(notes).Id);
}
/// <summary>
/// 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.
/// </summary>
[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<NoteDocumentSyncPayload>(reecrit);
Assert.NotNull(relu);
Assert.Equal(nouveau.ToString("D"), relu.DocumentId);
Assert.Equal("Note", relu.Contenu);
}
// ===================== REFUS =====================
/// <summary>
/// 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.
/// </summary>
[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<DocumentNoteItem> notes = await _service.GetNotesForDocumentAsync(documentGuid);
DocumentNoteItem note = Assert.Single(notes);
Assert.Equal("Note refusée", note.Contenu);
Assert.NotNull(note.SyncLabel);
}
}