Files
Omika/tests/Omika.Tests/Mobile/Local/DocumentLocalServiceTests.cs
2026-10-23 18:46:50 +02:00

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);
}
}