388 lines
15 KiB
C#
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);
|
|
}
|
|
}
|