1459 lines
58 KiB
C#
1459 lines
58 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Omika.App.Local.Abstractions;
|
|
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>
|
|
/// Tests du service local Documents / Photos : liste depuis SQLite, cache
|
|
/// fichier local, téléchargement à la demande, ouverture offline et ajout
|
|
/// photo/document offline-first (Outbox).
|
|
/// </summary>
|
|
public class DocumentLocalServiceTests : 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 DownloadCalls { get; private set; }
|
|
public int UploadCalls { get; private set; }
|
|
public int GetDocumentsCalls { get; private set; }
|
|
public bool ThrowOnDownload { get; set; }
|
|
public bool ThrowOnGetDocuments { get; set; } = true;
|
|
public IReadOnlyList<DocumentSummaryDto> DocumentsByInterventionToReturn { get; set; } = [];
|
|
public DocumentDownloadResponse? DownloadToReturn { get; set; }
|
|
public DocumentUploadResponse UploadToReturn { get; set; } = new() { Success = true };
|
|
|
|
public Task<IReadOnlyList<DocumentSummaryDto>> GetDocumentsByInterventionAsync(Guid interventionGuid, CancellationToken ct = default)
|
|
{
|
|
GetDocumentsCalls++;
|
|
if (ThrowOnGetDocuments)
|
|
{
|
|
throw new HttpRequestException("Refresh documents error");
|
|
}
|
|
return Task.FromResult(DocumentsByInterventionToReturn);
|
|
}
|
|
|
|
public Task<SyncOperationReceipt> SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<SyncStatusResponse> GetStatusAsync(Guid operationId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<MobileSnapshotResponse> GetSnapshotAsync(CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<DocumentDownloadResponse> DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
|
|
{
|
|
DownloadCalls++;
|
|
if (ThrowOnDownload)
|
|
{
|
|
throw new HttpRequestException("Download error");
|
|
}
|
|
return Task.FromResult(DownloadToReturn ?? new DocumentDownloadResponse
|
|
{
|
|
DocumentGuid = documentGuid,
|
|
NomFichier = "doc.pdf",
|
|
MimeType = "application/pdf",
|
|
TailleOctets = 10,
|
|
Contenu = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|
});
|
|
}
|
|
|
|
public Task<DocumentUploadResponse> UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default)
|
|
{
|
|
UploadCalls++;
|
|
return Task.FromResult(UploadToReturn);
|
|
}
|
|
|
|
public int GetNotesByDocumentCalls { get; private set; }
|
|
public IReadOnlyList<NoteInterneDto> NotesByDocumentToReturn { get; set; } = [];
|
|
public Exception? NotesByDocumentException { get; set; }
|
|
|
|
public Task<IReadOnlyList<NoteInterneDto>> GetNotesByDocumentAsync(Guid documentGuid, CancellationToken ct = default)
|
|
{
|
|
GetNotesByDocumentCalls++;
|
|
return NotesByDocumentException is not null
|
|
? Task.FromException<IReadOnlyList<NoteInterneDto>>(NotesByDocumentException)
|
|
: Task.FromResult(NotesByDocumentToReturn);
|
|
}
|
|
|
|
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 NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> ValidateAsync(
|
|
Omika.Core.Contracts.Mobile.License.LicenseValidateRequest request, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.License.MobileLicenseSessionResponse> GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<Omika.Core.Contracts.Mobile.Bootstrap.MobileBootstrapResponse> GetBootstrapAsync(CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
}
|
|
|
|
|
|
|
|
private sealed class FakeFileLauncher : IFileLauncher
|
|
{
|
|
public string? LastOpenedPath { get; private set; }
|
|
public bool Result { get; set; } = true;
|
|
public Task<bool> OpenAsync(string localPath)
|
|
{
|
|
LastOpenedPath = localPath;
|
|
return Task.FromResult(Result);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeFilePicker : IFilePicker
|
|
{
|
|
public PickedFile? PhotoToReturn { get; set; }
|
|
public PickedFile? DocumentToReturn { get; set; }
|
|
public IReadOnlyList<PickedFile> PhotosToReturn { get; set; } = [];
|
|
public Task<PickedFile?> PickPhotoAsync() => Task.FromResult(PhotoToReturn);
|
|
public Task<PickedFile?> PickDocumentAsync() => Task.FromResult(DocumentToReturn);
|
|
public Task<IReadOnlyList<PickedFile>> PickPhotosAsync() => Task.FromResult(PhotosToReturn);
|
|
}
|
|
|
|
private sealed class FakePhotoCapture : IPhotoCapture
|
|
{
|
|
public PickedFile? PhotoToReturn { get; set; }
|
|
public Exception? ExceptionToThrow { get; set; }
|
|
public Task<PickedFile?> CapturePhotoAsync(CancellationToken ct = default)
|
|
{
|
|
if (ExceptionToThrow is not null)
|
|
{
|
|
return Task.FromException<PickedFile?>(ExceptionToThrow);
|
|
}
|
|
return Task.FromResult(PhotoToReturn);
|
|
}
|
|
}
|
|
|
|
private readonly FakeNetwork _network = new();
|
|
private readonly FakeApiClient _api = new();
|
|
private readonly FakeFileLauncher _launcher = new();
|
|
private readonly FakeFilePicker _picker = new();
|
|
private readonly FakePhotoCapture _photoCapture = new();
|
|
private readonly FakeImageOptimizer _optimizer = new();
|
|
private readonly FakePhotoPolicyProvider _policy = new();
|
|
private readonly FakeDocumentDateResolver _dateResolver = new();
|
|
private readonly string _filesDirectory;
|
|
private readonly DocumentLocalService _service;
|
|
|
|
public DocumentLocalServiceTests()
|
|
{
|
|
_filesDirectory = Path.Combine(Path.GetTempPath(), "omika-tests", Guid.NewGuid().ToString("N"));
|
|
_service = new DocumentLocalService(
|
|
DocumentMetadataRepository,
|
|
NoteDocumentRepository,
|
|
DocumentCacheRepository,
|
|
OutboxRepository,
|
|
_api,
|
|
_launcher,
|
|
_picker,
|
|
_photoCapture,
|
|
_optimizer,
|
|
_policy,
|
|
_dateResolver,
|
|
_network,
|
|
ReconciliationService,
|
|
_filesDirectory,
|
|
NullLogger<DocumentLocalService>.Instance);
|
|
}
|
|
|
|
~DocumentLocalServiceTests()
|
|
{
|
|
if (Directory.Exists(_filesDirectory))
|
|
{
|
|
Directory.Delete(_filesDirectory, recursive: true);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private static string Serialize<T>(T value) => JsonSerializer.Serialize(value);
|
|
|
|
private static LocalDocumentMetadata CreateDocument(
|
|
Guid id,
|
|
Guid interventionId,
|
|
string nom,
|
|
string extension,
|
|
string? categorie = "Rapport",
|
|
string? mimeType = null,
|
|
DateTime? dateCreation = null)
|
|
{
|
|
var dto = new DocumentSummaryDto
|
|
{
|
|
Id = id.ToString(),
|
|
Nom = nom,
|
|
Titre = nom,
|
|
Categorie = categorie,
|
|
MimeType = mimeType,
|
|
Extension = extension,
|
|
TailleOctets = 1024,
|
|
DateCreation = dateCreation ?? new DateTime(2026, 8, 1),
|
|
InterventionId = interventionId.ToString()
|
|
};
|
|
return new LocalDocumentMetadata
|
|
{
|
|
Id = id,
|
|
InterventionId = interventionId,
|
|
PayloadJson = Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalDocumentMetadata CreatePhoto(
|
|
Guid id,
|
|
Guid interventionId,
|
|
string nom,
|
|
string extension,
|
|
string? categorie = "Photo",
|
|
string? mimeType = "image/jpeg")
|
|
=> CreateDocument(id, interventionId, nom, extension, categorie, mimeType);
|
|
|
|
// ===================== TESTS LISTE =====================
|
|
|
|
[Fact]
|
|
public async Task DocumentsDuneIntervention_Retournes()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "rapport.pdf", ".pdf"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Single(result);
|
|
Assert.Equal(docId, result[0].DocumentGuid);
|
|
Assert.Equal("rapport.pdf", result[0].Nom);
|
|
Assert.Equal(".pdf", result[0].Extension);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AucunDocument_RetourneListeVide()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OrdreCohérent_PhotosDabordPuisParDate()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docTard = Guid.NewGuid();
|
|
Guid photo = Guid.NewGuid();
|
|
Guid docTot = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docTard, interventionId, "tard.pdf", ".pdf", dateCreation: new DateTime(2026, 8, 10)));
|
|
await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "photo.jpg", ".jpg"));
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docTot, interventionId, "tot.pdf", ".pdf", dateCreation: new DateTime(2026, 8, 2)));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : la photo d'abord, puis les documents par date croissante.
|
|
Assert.Equal(3, result.Count);
|
|
Assert.Equal(photo, result[0].DocumentGuid);
|
|
Assert.Equal(docTot, result[1].DocumentGuid);
|
|
Assert.Equal(docTard, result[2].DocumentGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DocumentGuidConserve()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Equal(docId, result[0].DocumentGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MetadataInvalide_IgnoreeProprement()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid valide = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(valide, interventionId, "ok.pdf", ".pdf"));
|
|
await DocumentMetadataRepository.UpsertAsync(new LocalDocumentMetadata
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InterventionId = interventionId,
|
|
PayloadJson = "not-json",
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : seule la metadata valide est retournée.
|
|
Assert.Single(result);
|
|
Assert.Equal(valide, result[0].DocumentGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AucunTelechargementAutomatique()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
// Act : ouverture de l'écran (liste).
|
|
await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : aucun appel HTTP de téléchargement.
|
|
Assert.Equal(0, _api.DownloadCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DistinctionPhotoDocument_ParCategorie()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid photo = Guid.NewGuid();
|
|
Guid doc = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "photo.jpg", ".jpg", categorie: "Photo"));
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(doc, interventionId, "rapport.pdf", ".pdf", categorie: "Rapport"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.True(result.Single(d => d.DocumentGuid == photo).IsPhoto);
|
|
Assert.False(result.Single(d => d.DocumentGuid == doc).IsPhoto);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DistinctionPhotoDocument_ParMimeType()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid photo = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreatePhoto(photo, interventionId, "image.png", ".png", categorie: null, mimeType: "image/png"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : distinguée par MIME type même sans catégorie.
|
|
Assert.True(result[0].IsPhoto);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SansDonneeFiable_PresenteCommeDocument()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid doc = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(doc, interventionId, "fichier.bin", ".bin", categorie: null, mimeType: null));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> result = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : présenté comme document (pas de distinction fiable).
|
|
Assert.False(result[0].IsPhoto);
|
|
}
|
|
|
|
// ===================== TESTS CACHE =====================
|
|
|
|
[Fact]
|
|
public async Task DejaEnCache_AucunHTTP()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
string localPath = Path.Combine(_filesDirectory, "cached.pdf");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(localPath, [1, 2, 3]);
|
|
await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry
|
|
{
|
|
DocumentGuid = docId,
|
|
LocalPath = localPath,
|
|
NomFichier = "doc.pdf",
|
|
State = DocumentCacheState.Downloaded
|
|
});
|
|
|
|
// Act
|
|
bool result = await _service.EnsureDownloadedAsync(docId);
|
|
|
|
// Assert : aucun HTTP, déjà en cache.
|
|
Assert.True(result);
|
|
Assert.Equal(0, _api.DownloadCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonEnCache_Online_TelechargeEtSauvegarde()
|
|
{
|
|
// Arrange
|
|
Guid docId = Guid.NewGuid();
|
|
_api.DownloadToReturn = new DocumentDownloadResponse
|
|
{
|
|
DocumentGuid = docId,
|
|
NomFichier = "doc.pdf",
|
|
MimeType = "application/pdf",
|
|
TailleOctets = 4,
|
|
Contenu = [9, 8, 7, 6]
|
|
};
|
|
|
|
// Act
|
|
bool result = await _service.EnsureDownloadedAsync(docId);
|
|
|
|
// Assert : téléchargé, fichier écrit, entrée cache créée.
|
|
Assert.True(result);
|
|
Assert.Equal(1, _api.DownloadCalls);
|
|
DocumentCacheEntry? cache = await DocumentCacheRepository.GetAsync(docId);
|
|
Assert.NotNull(cache);
|
|
Assert.Equal(DocumentCacheState.Downloaded, cache.State);
|
|
Assert.True(File.Exists(cache.LocalPath));
|
|
Assert.Equal(4, new FileInfo(cache.LocalPath).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonEnCache_Offline_AucunHTTP_MetadataVisible()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
_network.Online = false;
|
|
|
|
// Act : la liste est visible, le téléchargement échoue sans HTTP.
|
|
IReadOnlyList<InterventionDocumentItem> list = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
bool download = await _service.EnsureDownloadedAsync(docId);
|
|
|
|
// Assert
|
|
Assert.Single(list);
|
|
Assert.False(list[0].IsCached);
|
|
Assert.False(download);
|
|
Assert.Equal(0, _api.DownloadCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EntreeCachePresenteMaisFichierAbsent_ConsidereInvalide()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
// Entrée cache pointant vers un fichier inexistant.
|
|
await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry
|
|
{
|
|
DocumentGuid = docId,
|
|
LocalPath = Path.Combine(_filesDirectory, "absent.pdf"),
|
|
NomFichier = "doc.pdf",
|
|
State = DocumentCacheState.Downloaded
|
|
});
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> list = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
bool download = await _service.EnsureDownloadedAsync(docId);
|
|
|
|
// Assert : le cache est considéré invalide (fichier absent), un
|
|
// téléchargement est tenté (online).
|
|
Assert.False(list[0].IsCached);
|
|
Assert.True(download);
|
|
Assert.Equal(1, _api.DownloadCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SnapshotRefresh_CacheConserve()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid docId = Guid.NewGuid();
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
string localPath = Path.Combine(_filesDirectory, "cached.pdf");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(localPath, [1, 2, 3]);
|
|
await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry
|
|
{
|
|
DocumentGuid = docId,
|
|
LocalPath = localPath,
|
|
NomFichier = "doc.pdf",
|
|
State = DocumentCacheState.Downloaded
|
|
});
|
|
|
|
// Simule un refresh snapshot : remplace la metadata métier.
|
|
await DocumentMetadataRepository.UpsertAsync(CreateDocument(docId, interventionId, "doc.pdf", ".pdf"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionDocumentItem> list = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
|
|
// Assert : le cache est retrouvé grâce au DocumentGuid.
|
|
Assert.True(list[0].IsCached);
|
|
}
|
|
|
|
// ===================== TESTS OUVERTURE =====================
|
|
|
|
[Fact]
|
|
public async Task OuvrirDocumentEnCache_AucunHTTP()
|
|
{
|
|
// Arrange
|
|
Guid docId = Guid.NewGuid();
|
|
string localPath = Path.Combine(_filesDirectory, "cached.pdf");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(localPath, [1, 2, 3]);
|
|
await DocumentCacheRepository.UpsertAsync(new DocumentCacheEntry
|
|
{
|
|
DocumentGuid = docId,
|
|
LocalPath = localPath,
|
|
NomFichier = "cached.pdf",
|
|
State = DocumentCacheState.Downloaded
|
|
});
|
|
|
|
// Act
|
|
bool result = await _service.OpenAsync(docId);
|
|
|
|
// Assert : ouverture de la copie locale, aucun HTTP.
|
|
Assert.True(result);
|
|
Assert.Equal(0, _api.DownloadCalls);
|
|
Assert.Equal(localPath, _launcher.LastOpenedPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OuvrirDocumentNonEnCache_RetourneFalse()
|
|
{
|
|
// Arrange : hors ligne, le repli de téléchargement est impossible.
|
|
_network.Online = false;
|
|
|
|
// Act
|
|
bool result = await _service.OpenAsync(Guid.NewGuid());
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
Assert.Null(_launcher.LastOpenedPath);
|
|
Assert.Equal(0, _api.DownloadCalls);
|
|
}
|
|
|
|
// ===================== TESTS AJOUT =====================
|
|
|
|
[Fact]
|
|
public async Task AjoutPhotoOffline_DocumentGuidGenere()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : DocumentGuid généré, photo, en cache, en attente de sync.
|
|
Assert.NotNull(item);
|
|
Assert.NotEqual(Guid.Empty, item.DocumentGuid);
|
|
Assert.True(item.IsPhoto);
|
|
Assert.True(item.IsCached);
|
|
Assert.Equal(DocumentSyncState.Pending, item.SyncState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutDocumentOffline_DocumentGuidGenere()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.NotNull(item);
|
|
Assert.NotEqual(Guid.Empty, item.DocumentGuid);
|
|
Assert.False(item.IsPhoto);
|
|
Assert.True(item.IsCached);
|
|
Assert.Equal(DocumentSyncState.Pending, item.SyncState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_OperationIdGenereEtDistinct()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : une opération Outbox existe avec OperationId ≠ DocumentGuid.
|
|
Assert.NotNull(item);
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = Assert.Single(ops);
|
|
Assert.Equal(LocalSyncEntityTypes.Document, op.EntityType);
|
|
Assert.Equal(item.DocumentGuid, op.RequestedEntityGuid);
|
|
Assert.NotEqual(item.DocumentGuid, op.OperationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_FichierCopieLocalement()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : le fichier est copié dans l'espace contrôlé.
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = Assert.Single(ops);
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(op.Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.True(File.Exists(payload.LocalPath));
|
|
Assert.NotEqual(source, payload.LocalPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_MetadataImmediatementVisible()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : la metadata est visible dans la liste.
|
|
IReadOnlyList<InterventionDocumentItem> list = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
Assert.Contains(list, d => d.DocumentGuid == item!.DocumentGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_OutboxCreee()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
Assert.Single(ops);
|
|
Assert.Equal(OutboxState.Pending, ops[0].State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_Redemarrage_OperationToujoursExploitable()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act : ajout puis "redémarrage" (relecture depuis SQLite/filesystem).
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = Assert.Single(ops);
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(op.Payload);
|
|
|
|
// Assert : la référence locale persiste et le fichier est toujours là.
|
|
Assert.NotNull(payload);
|
|
Assert.True(File.Exists(payload.LocalPath));
|
|
Assert.Equal(item!.DocumentGuid, payload.DocumentGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_Success_FichierConserve()
|
|
{
|
|
// Arrange
|
|
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? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Act : simule un SUCCESS (opération Succeeded).
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = ops.Single(o => o.RequestedEntityGuid == item!.DocumentGuid);
|
|
op.State = OutboxState.Succeeded;
|
|
await OutboxRepository.UpdateAsync(op);
|
|
|
|
// Assert : le fichier local est conservé après SUCCESS.
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(op.Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.True(File.Exists(payload.LocalPath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_Rejected_FichierConserve()
|
|
{
|
|
// Arrange
|
|
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? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Act : simule un REJECTED (opération Rejected).
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = ops.Single(o => o.RequestedEntityGuid == item!.DocumentGuid);
|
|
op.State = OutboxState.Rejected;
|
|
op.LastError = "ERR001: Rejeté";
|
|
await OutboxRepository.UpdateAsync(op);
|
|
|
|
// Assert : le fichier local est conservé après REJECTED.
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(op.Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.True(File.Exists(payload.LocalPath));
|
|
|
|
// La liste reflète l'état Rejected.
|
|
IReadOnlyList<InterventionDocumentItem> list = await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
InterventionDocumentItem rejected = list.Single(d => d.DocumentGuid == item!.DocumentGuid);
|
|
Assert.Equal(DocumentSyncState.Rejected, rejected.SyncState);
|
|
Assert.True(rejected.IsRejectedSync);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ajout_SourceTemporaireSupprimee_CopieInterneExploitable()
|
|
{
|
|
// Arrange
|
|
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" };
|
|
|
|
// Act
|
|
await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Supprime la source temporaire (comme le ferait le système).
|
|
File.Delete(source);
|
|
|
|
// Assert : la copie interne est toujours exploitable.
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
OutboxOperation op = Assert.Single(ops);
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(op.Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.True(File.Exists(payload.LocalPath));
|
|
Assert.NotEqual(source, payload.LocalPath);
|
|
}
|
|
|
|
// ===================== TESTS POLITIQUE DE POIDS DES PHOTOS =====================
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_Image_SoumiseALaPolitiqueDePoids()
|
|
{
|
|
// Arrange : limite client de 500 Ko.
|
|
_policy.MaxSizeKb = 500;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : l'optimiseur a été appelé avec la limite du client.
|
|
Assert.Equal(1, _optimizer.Calls);
|
|
Assert.Equal(500, _optimizer.LastMaxSizeKb);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_AucuneLimite_ImageNonTraitee()
|
|
{
|
|
// Arrange : 0 = aucune limite (politique client désactivée).
|
|
_policy.MaxSizeKb = 0;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : aucun traitement, le document est bien créé.
|
|
Assert.Equal(0, _optimizer.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutDocument_Pdf_JamaisOptimise()
|
|
{
|
|
// Arrange : même avec une limite active, un document non image n'est
|
|
// jamais soumis à l'optimisation.
|
|
_policy.MaxSizeKb = 500;
|
|
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" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : contenu copié à l'identique, optimizeur non appelé.
|
|
Assert.Equal(0, _optimizer.Calls);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(".pdf", item.Extension);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutDocument_ImageViaSelecteur_Optimisee()
|
|
{
|
|
// Arrange : le sélecteur de documents peut renvoyer une image ; elle doit
|
|
// alors être traitée comme telle.
|
|
_policy.MaxSizeKb = 2048;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.png");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.DocumentToReturn = new PickedFile { SourcePath = source, FileName = "scan.png", MimeType = "image/png" };
|
|
|
|
// Act
|
|
await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Equal(1, _optimizer.Calls);
|
|
Assert.Equal(2048, _optimizer.LastMaxSizeKb);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_ImageOptimisee_TailleEtFichierRefletentLeResultat()
|
|
{
|
|
// Arrange : l'optimiseur remplace le contenu par une version réduite.
|
|
_policy.MaxSizeKb = 500;
|
|
_optimizer.OptimizedBytes = [9, 9, 9, 9];
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3, 4, 5, 6, 7, 8]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : la taille annoncée est celle du fichier optimisé, et le fichier
|
|
// local référencé par l'Outbox est bien la version optimisée.
|
|
Assert.NotNull(item);
|
|
Assert.Equal(4, item.TailleOctets);
|
|
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(Assert.Single(ops).Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.Equal([9, 9, 9, 9], await File.ReadAllBytesAsync(payload.LocalPath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_UnSeulPassageDOptimisation()
|
|
{
|
|
// Arrange : garantit qu'une image n'est optimisée qu'UNE fois à l'ajout.
|
|
_policy.MaxSizeKb = 500;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
await _service.AddPhotoAsync(interventionId);
|
|
int apresAjout = _optimizer.Calls;
|
|
|
|
// La synchronisation relit le fichier local, sans repasser par l'optimiseur.
|
|
await _service.GetDocumentsForInterventionAsync(interventionId);
|
|
await _service.GetCachedFilePathAsync(
|
|
(await OutboxRepository.GetReadyAsync()).Single().RequestedEntityGuid);
|
|
|
|
// Assert
|
|
Assert.Equal(1, apresAjout);
|
|
Assert.Equal(1, _optimizer.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhotoAnnotee_Png_SoumisALaPolitiqueDePoids()
|
|
{
|
|
// Arrange : le PNG produit par l'éditeur d'annotation est une nouvelle
|
|
// image : il relève de la même politique.
|
|
_policy.MaxSizeKb = 1024;
|
|
_optimizer.OptimizedBytes = [7, 7];
|
|
Guid interventionId = Guid.NewGuid();
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync(
|
|
interventionId, Guid.Empty, [1, 2, 3, 4], "annotation.png");
|
|
|
|
// Assert
|
|
Assert.NotNull(item);
|
|
Assert.Equal(1, _optimizer.Calls);
|
|
Assert.Equal(1024, _optimizer.LastMaxSizeKb);
|
|
Assert.Equal("image/png", item.MimeType);
|
|
Assert.Equal(2, item.TailleOctets);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhotoAnnotee_AucuneLimite_PngConserve()
|
|
{
|
|
// Arrange
|
|
_policy.MaxSizeKb = 0;
|
|
Guid interventionId = Guid.NewGuid();
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync(
|
|
interventionId, Guid.Empty, [1, 2, 3, 4], "annotation.png");
|
|
|
|
// Assert : aucun traitement, contenu d'origine conservé.
|
|
Assert.NotNull(item);
|
|
Assert.Equal(0, _optimizer.Calls);
|
|
Assert.Equal(4, item.TailleOctets);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhotoAnnotee_HeriteDeLaDateDuDocumentSource()
|
|
{
|
|
// Arrange : la copie annotée est un DÉRIVÉ d'une photo existante. Le PNG
|
|
// produit par l'éditeur ne porte aucune métadonnée EXIF : la date doit donc
|
|
// être héritée de la photo source, pas recalculée.
|
|
var sourceDate = new DateTime(2025, 6, 23, 16, 42, 0);
|
|
Guid interventionId = Guid.NewGuid();
|
|
Guid sourceDocumentId = Guid.NewGuid();
|
|
|
|
await DocumentMetadataRepository.UpsertAsync(
|
|
CreateDocument(sourceDocumentId, interventionId, "IMG_001.jpg", ".jpg",
|
|
categorie: "Photo", mimeType: "image/jpeg", dateCreation: sourceDate));
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync(
|
|
interventionId, sourceDocumentId, [1, 2, 3, 4], "IMG_001_annotee.png");
|
|
|
|
// Assert
|
|
Assert.NotNull(item);
|
|
Assert.Equal(sourceDate, item.DateCreation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhotoAnnotee_DocumentSourceInconnu_RetombeSurLaDateSysteme()
|
|
{
|
|
// Arrange : identité source non résolue localement (cas d'un rafraîchissement
|
|
// partiel) : l'ajout ne doit jamais échouer pour autant.
|
|
Guid interventionId = Guid.NewGuid();
|
|
DateTime avant = DateTime.Now.AddSeconds(-1);
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddAnnotatedPhotoAsync(
|
|
interventionId, Guid.NewGuid(), [1, 2, 3, 4], "annotation.png");
|
|
|
|
// Assert
|
|
Assert.NotNull(item);
|
|
Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_DateExif_RemplaceLaDateDUpload()
|
|
{
|
|
// Arrange : photo prise le 10/09/2026 à 14:32, sélectionnée bien plus tard.
|
|
// La date du document doit être celle de la PRISE DE VUE.
|
|
var datePriseDeVue = new DateTime(2026, 9, 10, 14, 32, 18);
|
|
_dateResolver.ResolvedDate = datePriseDeVue;
|
|
_dateResolver.Source = DocumentDateSource.ExifDateTimeOriginal;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "IMG_4521.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : l'élément retourné porte la date réelle…
|
|
Assert.NotNull(item);
|
|
Assert.Equal(datePriseDeVue, item.DateCreation);
|
|
|
|
// …la métadonnée locale aussi (donc visible hors ligne et après redémarrage)…
|
|
IReadOnlyList<LocalDocumentMetadata> locaux = await DocumentMetadataRepository.GetAllAsync();
|
|
DocumentSummaryDto? dto = JsonSerializer.Deserialize<DocumentSummaryDto>(
|
|
Assert.Single(locaux).PayloadJson);
|
|
Assert.NotNull(dto);
|
|
Assert.Equal(datePriseDeVue, dto.DateCreation);
|
|
|
|
// …et l'Outbox la transporte jusqu'au serveur, sans quoi celui-ci
|
|
// horodaterait le document à l'instant de la synchronisation.
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(
|
|
Assert.Single(ops).Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.Equal(datePriseDeVue, payload.DateCreation);
|
|
Assert.Equal(nameof(DocumentDateSource.ExifDateTimeOriginal), payload.DateCreationSource);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_SansMetadonnee_UtiliseLaDateSysteme()
|
|
{
|
|
// Arrange : capture d'écran sans EXIF — le double retourne le repli système.
|
|
Guid interventionId = Guid.NewGuid();
|
|
DateTime avant = DateTime.Now.AddSeconds(-1);
|
|
string source = Path.Combine(_filesDirectory, "source.png");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "capture.png", MimeType = "image/png" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : aucune erreur, et la date correspond au moment de l'ajout.
|
|
Assert.NotNull(item);
|
|
Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_DateLueAvantOptimisation()
|
|
{
|
|
// Arrange : l'optimisation réencode l'image et peut supprimer les
|
|
// métadonnées EXIF. La date doit donc être lue SUR LE FICHIER SOURCE, et
|
|
// avant que l'optimiseur ne soit appelé.
|
|
_policy.MaxSizeKb = 500;
|
|
_optimizer.OptimizedBytes = [9, 9];
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : la date a été résolue sur le chemin d'origine, jamais sur le
|
|
// chemin du fichier optimisé.
|
|
Assert.Equal(1, _dateResolver.Calls);
|
|
Assert.Equal(source, _dateResolver.LastPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_SansLimiteDePoids_LaDateEstQuandMemeResolue()
|
|
{
|
|
// Arrange : POIDS_MAX_PHOTO_KO = 0 (aucune limite) court-circuite
|
|
// l'optimisation ; la date métier ne doit pas en dépendre.
|
|
_policy.MaxSizeKb = 0;
|
|
_dateResolver.ResolvedDate = new DateTime(2024, 3, 4, 5, 6, 7);
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile { SourcePath = source, FileName = "photo.jpg", MimeType = "image/jpeg" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Equal(0, _optimizer.Calls);
|
|
Assert.Equal(1, _dateResolver.Calls);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(new DateTime(2024, 3, 4, 5, 6, 7), item.DateCreation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutDocument_NonImage_LaDateEstQuandMemeResolue()
|
|
{
|
|
// Arrange : un PDF passe par le même point d'entrée : la résolution de date
|
|
// y est tentée aussi (elle retombe sur le repli système si rien de fiable).
|
|
_policy.MaxSizeKb = 500;
|
|
_dateResolver.ResolvedDate = new DateTime(2023, 1, 2, 3, 4, 5);
|
|
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" };
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert
|
|
Assert.Equal(1, _dateResolver.Calls);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(new DateTime(2023, 1, 2, 3, 4, 5), item.DateCreation);
|
|
}
|
|
|
|
// ===================== TESTS MULTI-PHOTO & CAMÉRA =====================
|
|
|
|
[Fact]
|
|
public async Task AddPhotos_PickerVide_AucunAjout()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
_picker.PhotosToReturn = [];
|
|
|
|
// Act
|
|
AddPhotosResult result = await _service.AddPhotosAsync(interventionId);
|
|
|
|
// Assert : sélection annulée → aucun document, aucune opération Outbox.
|
|
Assert.True(result.Cancelled);
|
|
Assert.Empty(result.Added);
|
|
Assert.Equal(0, result.FailedCount);
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotos_UneImage_UnDocumentCree()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "p1.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotosToReturn =
|
|
[
|
|
new PickedFile { SourcePath = source, FileName = "p1.jpg", MimeType = "image/jpeg" }
|
|
];
|
|
|
|
// Act
|
|
AddPhotosResult result = await _service.AddPhotosAsync(interventionId);
|
|
|
|
// Assert
|
|
InterventionDocumentItem added = Assert.Single(result.Added);
|
|
Assert.Equal(0, result.FailedCount);
|
|
Assert.True(added.IsPhoto);
|
|
Assert.Single(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotos_TroisImages_TroisDocumentsCrees()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
var picked = new List<PickedFile>();
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
string source = Path.Combine(_filesDirectory, $"p{i}.jpg");
|
|
await File.WriteAllBytesAsync(source, [(byte)i, 2, 3]);
|
|
picked.Add(new PickedFile { SourcePath = source, FileName = $"p{i}.jpg", MimeType = "image/jpeg" });
|
|
}
|
|
_picker.PhotosToReturn = picked;
|
|
|
|
// Act
|
|
AddPhotosResult result = await _service.AddPhotosAsync(interventionId);
|
|
|
|
// Assert : 3 documents distincts, 3 opérations Outbox, aucun échec.
|
|
Assert.Equal(3, result.Added.Count);
|
|
Assert.Equal(0, result.FailedCount);
|
|
Assert.Equal(3, result.Added.Select(a => a.DocumentGuid).Distinct().Count());
|
|
Assert.Equal(3, (await OutboxRepository.GetReadyAsync()).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotos_UneEchoue_LesAutresRestentTraitees()
|
|
{
|
|
// Arrange : 3 fichiers, celui du milieu n'existe pas (sa préparation échouera).
|
|
Guid interventionId = Guid.NewGuid();
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
string ok1 = Path.Combine(_filesDirectory, "ok1.jpg");
|
|
string ok2 = Path.Combine(_filesDirectory, "ok2.jpg");
|
|
await File.WriteAllBytesAsync(ok1, [1, 2, 3]);
|
|
await File.WriteAllBytesAsync(ok2, [4, 5, 6]);
|
|
string missing = Path.Combine(_filesDirectory, "ko.jpg");
|
|
_picker.PhotosToReturn =
|
|
[
|
|
new PickedFile { SourcePath = ok1, FileName = "ok1.jpg", MimeType = "image/jpeg" },
|
|
new PickedFile { SourcePath = missing, FileName = "ko.jpg", MimeType = "image/jpeg" },
|
|
new PickedFile { SourcePath = ok2, FileName = "ok2.jpg", MimeType = "image/jpeg" }
|
|
];
|
|
|
|
// Act
|
|
AddPhotosResult result = await _service.AddPhotosAsync(interventionId);
|
|
|
|
// Assert : les 2 succès sont conservés, l'échec est compté sans interrompre.
|
|
Assert.Equal(2, result.Added.Count);
|
|
Assert.Equal(1, result.FailedCount);
|
|
Assert.Equal(2, (await OutboxRepository.GetReadyAsync()).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotoFromCamera_Annule_AucunDocument()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
_photoCapture.PhotoToReturn = null;
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoFromCameraAsync(interventionId);
|
|
|
|
// Assert : aucun document, aucune opération Outbox, aucune erreur.
|
|
Assert.Null(item);
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotoFromCamera_PhotoRetournee_MemePipelineQuePicker()
|
|
{
|
|
// Arrange : la caméra produit un fichier temporaire contrôlé par Omika.
|
|
Guid interventionId = Guid.NewGuid();
|
|
string tempCapture = Path.Combine(_filesDirectory, "capture.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(tempCapture, [1, 2, 3]);
|
|
_photoCapture.PhotoToReturn = new PickedFile
|
|
{
|
|
SourcePath = tempCapture,
|
|
FileName = "capture.jpg",
|
|
MimeType = "image/jpeg"
|
|
};
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoFromCameraAsync(interventionId);
|
|
|
|
// Assert : même traitement qu'une image sélectionnée (photo, cache, Outbox).
|
|
Assert.NotNull(item);
|
|
Assert.True(item.IsPhoto);
|
|
Assert.True(item.IsCached);
|
|
Assert.Equal(DocumentSyncState.Pending, item.SyncState);
|
|
Assert.Single(await OutboxRepository.GetReadyAsync());
|
|
// Le fichier temporaire caméra a été consommé puis supprimé.
|
|
Assert.False(File.Exists(tempCapture));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddPhotoFromCamera_LauncherEchoue_PropageErreurControlee()
|
|
{
|
|
// Arrange
|
|
Guid interventionId = Guid.NewGuid();
|
|
_photoCapture.ExceptionToThrow = new PhotoCaptureException("Caméra indisponible.");
|
|
|
|
// Act + Assert : erreur contrôlée (pas d'exception générique), aucun document.
|
|
await Assert.ThrowsAsync<PhotoCaptureException>(
|
|
() => _service.AddPhotoFromCameraAsync(interventionId));
|
|
|
|
Assert.Empty(await OutboxRepository.GetReadyAsync());
|
|
}
|
|
|
|
// ===================== TESTS DATE DOCUMENT (SourceDate) =====================
|
|
|
|
[Fact]
|
|
public async Task AjoutDocument_AvecSourceDate_UtiliseLaDateSource()
|
|
{
|
|
// Arrange : le sélecteur fournit une date source (SAF LastModified).
|
|
var sourceDate = new DateTimeOffset(2023, 11, 22, 10, 30, 0, TimeSpan.FromHours(1));
|
|
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",
|
|
SourceDate = sourceDate
|
|
};
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : la date du document est la date source (pas la date du jour).
|
|
Assert.NotNull(item);
|
|
Assert.Equal(sourceDate.ToLocalTime().DateTime, item.DateCreation);
|
|
|
|
// La date source traverse l'Outbox (upload serveur) avec son origine.
|
|
IReadOnlyList<OutboxOperation> ops = await OutboxRepository.GetReadyAsync();
|
|
DocumentOutboxPayload? payload = JsonSerializer.Deserialize<DocumentOutboxPayload>(
|
|
Assert.Single(ops).Payload);
|
|
Assert.NotNull(payload);
|
|
Assert.Equal(sourceDate.ToLocalTime().DateTime, payload.DateCreation);
|
|
Assert.Equal(nameof(DocumentDateSource.SourceFileDate), payload.DateCreationSource);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutDocument_SansSourceDate_RetombeSurLaDateDUpload()
|
|
{
|
|
// Arrange : aucune date source (fournisseur SAF muet) → repli date système.
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.pdf");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
DateTime avant = DateTime.Now.AddSeconds(-1);
|
|
_picker.DocumentToReturn = new PickedFile
|
|
{
|
|
SourcePath = source,
|
|
FileName = "doc.pdf",
|
|
MimeType = "application/pdf",
|
|
SourceDate = null
|
|
};
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddDocumentAsync(interventionId);
|
|
|
|
// Assert : repli sur la date système de l'ajout.
|
|
Assert.NotNull(item);
|
|
Assert.InRange(item.DateCreation, avant, DateTime.Now.AddSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AjoutPhoto_AvecSourceDate_IgnoreLaDateSource_EtConserveExif()
|
|
{
|
|
// Arrange : une photo porte une date source parasite ; la date EXIF reste
|
|
// prioritaire (la logique photo ne doit PAS être modifiée).
|
|
var datePriseDeVue = new DateTime(2026, 9, 10, 14, 32, 18);
|
|
_dateResolver.ResolvedDate = datePriseDeVue;
|
|
_dateResolver.Source = DocumentDateSource.ExifDateTimeOriginal;
|
|
Guid interventionId = Guid.NewGuid();
|
|
string source = Path.Combine(_filesDirectory, "source.jpg");
|
|
Directory.CreateDirectory(_filesDirectory);
|
|
await File.WriteAllBytesAsync(source, [1, 2, 3]);
|
|
_picker.PhotoToReturn = new PickedFile
|
|
{
|
|
SourcePath = source,
|
|
FileName = "photo.jpg",
|
|
MimeType = "image/jpeg",
|
|
SourceDate = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
|
};
|
|
|
|
// Act
|
|
InterventionDocumentItem? item = await _service.AddPhotoAsync(interventionId);
|
|
|
|
// Assert : la date EXIF est conservée, la SourceDate est ignorée.
|
|
Assert.NotNull(item);
|
|
Assert.Equal(datePriseDeVue, item.DateCreation);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hors ligne, l'ajout d'une note sur une photo doit aboutir : la note est
|
|
/// enregistrée sur l'appareil et aucune connexion n'est tentée. C'est le
|
|
/// scénario terrain (intervention sans réseau) qui était impossible avant :
|
|
/// les notes d'un document n'existaient que côté serveur.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NotesDocument_HorsLigne_AjoutLocalSansAucunAppelReseau()
|
|
{
|
|
// Arrange
|
|
_network.Online = false;
|
|
Guid documentId = Guid.NewGuid();
|
|
|
|
// Act
|
|
DocumentNoteItem note = await _service.AddNoteAsync(documentId, " Radiateur HS ");
|
|
|
|
// Assert : la note existe en local, nettoyée, et rien n'est parti au serveur.
|
|
Assert.Equal("Radiateur HS", note.Contenu);
|
|
Assert.True(note.EnAttenteSynchronisation);
|
|
Assert.Equal(0, _api.GetNotesByDocumentCalls);
|
|
IReadOnlyList<LocalNoteDocument> locales =
|
|
await NoteDocumentRepository.GetByDocumentAsync(documentId);
|
|
Assert.Equal("Radiateur HS", Assert.Single(locales).Contenu);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hors ligne, la liste des notes est servie depuis l'appareil : elle contient
|
|
/// la note saisie, marquée comme en attente d'envoi, et aucun appel HTTP n'est
|
|
/// tenté.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NotesDocument_HorsLigne_ListeServieDepuisLAppareil()
|
|
{
|
|
// Arrange
|
|
_network.Online = false;
|
|
Guid documentId = Guid.NewGuid();
|
|
await _service.AddNoteAsync(documentId, "Note saisie hors ligne");
|
|
|
|
// Act
|
|
IReadOnlyList<DocumentNoteItem> notes = await _service.GetNotesForDocumentAsync(documentId);
|
|
|
|
// Assert
|
|
DocumentNoteItem note = Assert.Single(notes);
|
|
Assert.Equal("Note saisie hors ligne", note.Contenu);
|
|
Assert.Equal("En attente d'envoi", note.SyncLabel);
|
|
Assert.Equal(0, _api.GetNotesByDocumentCalls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// En ligne, les notes confirmées par le serveur sont mises en cache local puis
|
|
/// servies depuis l'appareil (elles restent donc consultables hors ligne).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NotesDocument_EnLigne_NotesServeurMisesEnCache()
|
|
{
|
|
// Arrange
|
|
Guid documentId = Guid.NewGuid();
|
|
Guid noteId = Guid.NewGuid();
|
|
_api.NotesByDocumentToReturn =
|
|
[
|
|
new NoteInterneDto { Id = noteId.ToString(), Contenu = "Note serveur", AuteurNom = "Martin" }
|
|
];
|
|
|
|
// Act
|
|
IReadOnlyList<DocumentNoteItem> notes = await _service.GetNotesForDocumentAsync(documentId);
|
|
|
|
// Assert
|
|
Assert.Equal(1, _api.GetNotesByDocumentCalls);
|
|
DocumentNoteItem note = Assert.Single(notes);
|
|
Assert.Equal("Note serveur", note.Contenu);
|
|
Assert.Null(note.SyncLabel);
|
|
Assert.Equal([noteId], (await NoteDocumentRepository.GetByDocumentAsync(documentId)).Select(n => n.Id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// En ligne mais serveur injoignable : l'échec ne remonte plus (le panneau
|
|
/// notes ne doit jamais casser l'écran) et la copie locale est conservée.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NotesDocument_EchecReseau_CopieLocaleConservee()
|
|
{
|
|
// Arrange : une note locale déjà enregistrée, puis un serveur en échec.
|
|
Guid documentId = Guid.NewGuid();
|
|
await NoteDocumentRepository.UpsertAsync(new LocalNoteDocument
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DocumentId = documentId,
|
|
Contenu = "Note locale",
|
|
DateCreation = new DateTime(2026, 9, 10, 14, 0, 0)
|
|
});
|
|
_api.NotesByDocumentException = new HttpRequestException("Connection failure");
|
|
|
|
// Act
|
|
IReadOnlyList<DocumentNoteItem> notes = await _service.GetNotesForDocumentAsync(documentId);
|
|
|
|
// Assert
|
|
Assert.Equal(1, _api.GetNotesByDocumentCalls);
|
|
Assert.Equal("Note locale", Assert.Single(notes).Contenu);
|
|
}
|
|
}
|