Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user