497 lines
19 KiB
C#
497 lines
19 KiB
C#
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using Omika.App.Local.Abstractions;
|
|
using Omika.App.Local.Models;
|
|
using Omika.App.Local.Repositories;
|
|
using Omika.App.Local.Services;
|
|
using Omika.Core.Contracts.Common;
|
|
using Omika.Core.Contracts.Interventions;
|
|
using Omika.Core.Contracts.Mobile.Sync;
|
|
using Omika.Core.Contracts.Organisation;
|
|
using Omika.Core.Contracts.Planning;
|
|
|
|
namespace Omika.Tests.Mobile.Local;
|
|
|
|
/// <summary>
|
|
/// Tests du service de lecture de la liste des interventions depuis les données
|
|
/// locales SQLite. Vérifie le tri déterministe, la recherche, l'ignorance des
|
|
/// payloads invalides, la conservation du GUID public et l'absence d'appel API.
|
|
/// </summary>
|
|
public class InterventionLocalServiceTests : LocalTestBase
|
|
{
|
|
private readonly InterventionLocalService _service;
|
|
|
|
public InterventionLocalServiceTests()
|
|
{
|
|
_service = new InterventionLocalService(
|
|
InterventionRepository, ClientRepository, SiteRepository, DossierRepository,
|
|
RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository,
|
|
OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database);
|
|
}
|
|
|
|
private static string Serialize<T>(T value) => JsonSerializer.Serialize(value);
|
|
|
|
private static LocalIntervention CreateIntervention(
|
|
Guid id,
|
|
string reference,
|
|
string? titre = null,
|
|
DateTime? debut = null,
|
|
string? etat = null,
|
|
bool estArchive = false,
|
|
Guid? clientId = null,
|
|
Guid? siteId = null,
|
|
Guid? dossierId = null,
|
|
IReadOnlyList<Guid>? collaborateurIds = null)
|
|
{
|
|
var dto = new InterventionDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Reference = reference,
|
|
Titre = titre,
|
|
DatePrevueDebut = debut,
|
|
Etat = etat is null ? null : new ReferenceValueDto { Label = etat },
|
|
EstArchive = estArchive,
|
|
ClientId = clientId?.ToString(),
|
|
SiteId = siteId?.ToString(),
|
|
DossierId = dossierId?.ToString(),
|
|
CollaborateurIds = collaborateurIds?.Select(g => g.ToString()).ToList() ?? []
|
|
};
|
|
return new LocalIntervention
|
|
{
|
|
Id = id,
|
|
ClientId = clientId,
|
|
SiteId = siteId,
|
|
DossierId = dossierId,
|
|
PayloadJson = Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static LocalCollaborateur CreateCollaborateur(Guid id, string nom, string prenom)
|
|
{
|
|
var dto = new CollaborateurDetailsDto
|
|
{
|
|
Id = id.ToString(),
|
|
Nom = nom,
|
|
Prenom = prenom
|
|
};
|
|
return new LocalCollaborateur
|
|
{
|
|
Id = id,
|
|
PayloadJson = Serialize(dto),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_Aucune_ListeVide()
|
|
{
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_Une_RetourneLaListe()
|
|
{
|
|
// Arrange
|
|
Guid id = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001", "Intervention A"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert
|
|
var item = Assert.Single(result);
|
|
Assert.Equal(id, item.InterventionGuid);
|
|
Assert.Equal("INT-001", item.Reference);
|
|
Assert.Equal("Intervention A", item.Titre);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_Plusieurs_TrieParDatePuisReference()
|
|
{
|
|
// Arrange
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(
|
|
Guid.NewGuid(), "INT-002", debut: new DateTime(2026, 8, 15)));
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(
|
|
Guid.NewGuid(), "INT-001", debut: new DateTime(2026, 8, 10)));
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(
|
|
Guid.NewGuid(), "INT-003", debut: new DateTime(2026, 8, 10)));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert : tri déterministe par date prévue puis référence.
|
|
Assert.Equal(3, result.Count);
|
|
Assert.Equal("INT-001", result[0].Reference);
|
|
Assert.Equal("INT-003", result[1].Reference);
|
|
Assert.Equal("INT-002", result[2].Reference);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_PayloadInvalide_Ignoree()
|
|
{
|
|
// Arrange
|
|
await InterventionRepository.UpsertAsync(new LocalIntervention
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PayloadJson = "not-json",
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert : l'entrée invalide est ignorée sans erreur.
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_GuidPublicConserve()
|
|
{
|
|
// Arrange
|
|
Guid id = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001"));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert : le GUID public est conservé (jamais de PK HFSQL).
|
|
Assert.Equal(id, Assert.Single(result).InterventionGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_RelationsResolues()
|
|
{
|
|
// Arrange
|
|
Guid clientId = Guid.NewGuid();
|
|
Guid siteId = Guid.NewGuid();
|
|
Guid dossierId = Guid.NewGuid();
|
|
Guid collaborateurId = Guid.NewGuid();
|
|
Guid interventionId = Guid.NewGuid();
|
|
|
|
await ClientRepository.UpsertAsync(new LocalClient
|
|
{
|
|
Id = clientId,
|
|
PayloadJson = Serialize(new Omika.Core.Contracts.Clients.ClientDetailsDto
|
|
{
|
|
Id = clientId.ToString(),
|
|
Nom = "Client Alpha"
|
|
}),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
await SiteRepository.UpsertAsync(new LocalSite
|
|
{
|
|
Id = siteId,
|
|
PayloadJson = Serialize(new Omika.Core.Contracts.Sites.SiteDetailsDto
|
|
{
|
|
Id = siteId.ToString(),
|
|
Nom = "Site Beta"
|
|
}),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
await DossierRepository.UpsertAsync(new LocalDossier
|
|
{
|
|
Id = dossierId,
|
|
PayloadJson = Serialize(new Omika.Core.Contracts.Interventions.DossierDetailsDto
|
|
{
|
|
Id = dossierId.ToString(),
|
|
ReferenceMetier = "DOS-001"
|
|
}),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean"));
|
|
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(
|
|
interventionId, "INT-001", clientId: clientId, siteId: siteId,
|
|
dossierId: dossierId, collaborateurIds: [collaborateurId]));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert : les relations Client/Site/Dossier/Collaborateur sont résolues localement.
|
|
var item = Assert.Single(result);
|
|
Assert.Equal("Client Alpha", item.ClientNom);
|
|
Assert.Equal("Site Beta", item.SiteNom);
|
|
Assert.Equal("DOS-001", item.DossierReference);
|
|
Assert.Contains("Jean Dupont", item.Collaborateurs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventions_EstArchive_Conserve()
|
|
{
|
|
// Arrange
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(Guid.NewGuid(), "INT-001", estArchive: true));
|
|
|
|
// Act
|
|
IReadOnlyList<InterventionListItem> result = await _service.GetInterventionsAsync();
|
|
|
|
// Assert : le flag archivé est conservé pour le filtre.
|
|
Assert.True(Assert.Single(result).EstArchive);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_EcritLocalementEtEnqueueOutboxCreate()
|
|
{
|
|
// Arrange
|
|
var payload = new InterventionSyncPayload
|
|
{
|
|
Reference = "INT-NEW",
|
|
Titre = "Nouvelle intervention",
|
|
DatePrevueDebut = new DateTime(2026, 9, 1, 9, 0, 0)
|
|
};
|
|
|
|
// Act
|
|
Guid requestedGuid = await _service.CreateAsync(payload);
|
|
|
|
// Assert : l'entité locale est créée avec le GUID public (A).
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(requestedGuid);
|
|
Assert.NotNull(local);
|
|
Assert.Equal("INT-NEW", JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson)?.Reference);
|
|
|
|
// Assert : une opération Outbox CREATE est enqueue (RequestedEntityGuid = A).
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
var op = Assert.Single(ready);
|
|
Assert.Equal(SyncOperationType.Create, op.OperationType);
|
|
Assert.Equal(requestedGuid, op.RequestedEntityGuid);
|
|
Assert.Equal("Intervention", op.EntityType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_ModifieLocalementEtEnqueueOutboxUpdate()
|
|
{
|
|
// Arrange : une intervention locale existante.
|
|
Guid id = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001", "Avant"));
|
|
var payload = new InterventionSyncPayload { Titre = "Après" };
|
|
|
|
// Act
|
|
Guid target = await _service.UpdateAsync(id, payload);
|
|
|
|
// Assert : l'entité locale est mise à jour.
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(id);
|
|
Assert.NotNull(local);
|
|
Assert.Equal("Après", JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson)?.Titre);
|
|
|
|
// Assert : une opération Outbox UPDATE est enqueue.
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
var op = Assert.Single(ready);
|
|
Assert.Equal(SyncOperationType.Update, op.OperationType);
|
|
Assert.Equal(target, op.RequestedEntityGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArchiveAsync_ArchiveLocalementEtEnqueueOutboxArchive()
|
|
{
|
|
// Arrange : une intervention locale existante.
|
|
Guid id = Guid.NewGuid();
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(id, "INT-001"));
|
|
|
|
// Act
|
|
Guid target = await _service.ArchiveAsync(id);
|
|
|
|
// Assert : l'entité locale est archivée (EstArchive = true).
|
|
LocalIntervention? local = await InterventionRepository.GetByIdAsync(id);
|
|
Assert.NotNull(local);
|
|
Assert.True(JsonSerializer.Deserialize<InterventionDetailsDto>(local!.PayloadJson)?.EstArchive);
|
|
|
|
// Assert : une opération Outbox ARCHIVE est enqueue.
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
var op = Assert.Single(ready);
|
|
Assert.Equal(SyncOperationType.Archive, op.OperationType);
|
|
Assert.Equal(target, op.RequestedEntityGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_DeuxCreations_DeuxOperationIdsDistincts()
|
|
{
|
|
// Arrange
|
|
var payloadA = new InterventionSyncPayload { Reference = "INT-A" };
|
|
var payloadB = new InterventionSyncPayload { Reference = "INT-B" };
|
|
|
|
// Act
|
|
Guid guidA = await _service.CreateAsync(payloadA);
|
|
Guid guidB = await _service.CreateAsync(payloadB);
|
|
|
|
// Assert : deux identités mobiles distinctes et deux opérations Outbox.
|
|
Assert.NotEqual(guidA, guidB);
|
|
IReadOnlyList<OutboxOperation> ready = await OutboxRepository.GetReadyAsync();
|
|
Assert.Equal(2, ready.Count);
|
|
Assert.Equal(2, ready.Select(o => o.OperationId).Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetInterventionByIdAsync_RetourneRendezVousLie()
|
|
{
|
|
// Arrange : intervention + rendez-vous lié (colonne InterventionId peuplée,
|
|
// comme après l'application du snapshot corrigé).
|
|
Guid interventionGuid = Guid.NewGuid();
|
|
Guid rendezVousGuid = Guid.NewGuid();
|
|
|
|
await InterventionRepository.UpsertAsync(CreateIntervention(interventionGuid, "INT-RDV"));
|
|
await RendezVousRepository.UpsertAsync(new LocalRendezVous
|
|
{
|
|
Id = rendezVousGuid,
|
|
InterventionId = interventionGuid,
|
|
PayloadJson = Serialize(new RendezVousDetailsDto
|
|
{
|
|
Id = rendezVousGuid.ToString("D"),
|
|
InterventionId = interventionGuid.ToString("D"),
|
|
Titre = "RDV lié",
|
|
Debut = new DateTime(2026, 8, 19, 9, 0, 0)
|
|
}),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
// Act : repli local (le stub API retourne null) → MapToDetailAsync.
|
|
InterventionDetail? detail = await _service.GetInterventionByIdAsync(interventionGuid);
|
|
|
|
// Assert : la fiche intervention expose le rendez-vous lié.
|
|
Assert.NotNull(detail);
|
|
var rendezVous = Assert.Single(detail!.RendezVous);
|
|
Assert.Equal(rendezVousGuid, rendezVous.RendezVousGuid);
|
|
}
|
|
|
|
// ===== Lecture d'un compte rendu : offline-first et échec réseau =====
|
|
|
|
/// <summary>
|
|
/// Client API dont la lecture de compte rendu est configurable : nombre d'appels
|
|
/// observés (preuve d'absence d'appel réseau) et exception à lever (reproduction
|
|
/// du payload de production « Connection failure »).
|
|
///
|
|
/// L'interface est redéclarée volontairement : <c>GetCompteRenduByRendezVousAsync</c>
|
|
/// est une implémentation par défaut de <see cref="IMobileApiClient"/>, et une classe
|
|
/// dérivée ne peut la remplacer qu'en réimplémentant explicitement l'interface.
|
|
/// </summary>
|
|
private sealed class CompteRenduApiClient : StubMobileApiClient, IMobileApiClient
|
|
{
|
|
public int CompteRenduCalls { get; private set; }
|
|
|
|
public Exception? ExceptionToThrow { get; set; }
|
|
|
|
public CompteRenduDto? Result { get; set; }
|
|
|
|
public Task<CompteRenduDto?> GetCompteRenduByRendezVousAsync(
|
|
Guid rendezVousGuid,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
CompteRenduCalls++;
|
|
return ExceptionToThrow is { } ex
|
|
? Task.FromException<CompteRenduDto?>(ex)
|
|
: Task.FromResult(Result);
|
|
}
|
|
}
|
|
|
|
/// <summary>Service réel câblé sur le client API et la connectivité fournis.</summary>
|
|
private InterventionLocalService CreateService(IMobileApiClient apiClient, bool online)
|
|
{
|
|
Network.Online = online;
|
|
return new InterventionLocalService(
|
|
InterventionRepository, ClientRepository, SiteRepository, DossierRepository,
|
|
RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository,
|
|
OutboxService, ReconciliationService, apiClient, InterventionCacheRepository,
|
|
ContextInterventionCacheRepository, Database, Network);
|
|
}
|
|
|
|
/// <summary>Pose la copie locale d'un compte rendu (dernier état connu).</summary>
|
|
private async Task SeedCompteRenduLocalAsync(Guid rendezVousGuid, string note)
|
|
{
|
|
var repository = new CompteRenduLocalRepository(Database);
|
|
await repository.UpsertAsync(new LocalCompteRendu
|
|
{
|
|
RendezVousId = rendezVousGuid,
|
|
PayloadJson = Serialize(new CompteRenduDto
|
|
{
|
|
Id = Guid.NewGuid().ToString("D"),
|
|
RendezVousId = rendezVousGuid.ToString("D"),
|
|
NoteCollaborateur = note
|
|
}),
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Régression du ticket de production du 09/10 (message « Connection failure ») :
|
|
/// la vérification d'existence du compte rendu — anti-doublon du bouton Créer —
|
|
/// échouait et remontait jusqu'au ViewModel, qui la journalisait en P1. Un échec
|
|
/// réseau ne doit plus jamais lever : au pire, l'absence est constatée localement.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetCompteRenduByRendezVousAsync_EchecReseau_SansCopieLocale_NeLevePas()
|
|
{
|
|
var client = new CompteRenduApiClient
|
|
{
|
|
ExceptionToThrow = new HttpRequestException("Connection failure")
|
|
};
|
|
InterventionLocalService service = CreateService(client, online: true);
|
|
|
|
CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(Guid.NewGuid());
|
|
|
|
Assert.Null(result);
|
|
Assert.Equal(1, client.CompteRenduCalls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Échec réseau avec copie locale disponible : la copie reste exploitable, ce qui
|
|
/// préserve l'anti-doublon (un compte rendu déjà connu ne doit pas être recréé).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetCompteRenduByRendezVousAsync_EchecReseau_RetourneLaCopieLocale()
|
|
{
|
|
Guid rendezVousGuid = Guid.NewGuid();
|
|
await SeedCompteRenduLocalAsync(rendezVousGuid, "Note locale");
|
|
var client = new CompteRenduApiClient
|
|
{
|
|
ExceptionToThrow = new HttpRequestException("Connection failure")
|
|
};
|
|
InterventionLocalService service = CreateService(client, online: true);
|
|
|
|
CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(rendezVousGuid);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("Note locale", result!.NoteCollaborateur);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hors ligne, l'appel réseau n'est pas tenté du tout (aucune latence d'échec) :
|
|
/// la copie locale est retournée directement.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetCompteRenduByRendezVousAsync_HorsLigne_AucunAppelReseau()
|
|
{
|
|
Guid rendezVousGuid = Guid.NewGuid();
|
|
await SeedCompteRenduLocalAsync(rendezVousGuid, "Note locale");
|
|
var client = new CompteRenduApiClient();
|
|
InterventionLocalService service = CreateService(client, online: false);
|
|
|
|
CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(rendezVousGuid);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("Note locale", result!.NoteCollaborateur);
|
|
Assert.Equal(0, client.CompteRenduCalls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hors ligne sans copie locale : « inconnu » est traité comme « absent » — choix
|
|
/// assumé de l'offline-first (le compte rendu sera créé localement), sans réseau.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetCompteRenduByRendezVousAsync_HorsLigne_SansCopieLocale_RetourneNull()
|
|
{
|
|
var client = new CompteRenduApiClient
|
|
{
|
|
Result = new CompteRenduDto { NoteCollaborateur = "Serveur" }
|
|
};
|
|
InterventionLocalService service = CreateService(client, online: false);
|
|
|
|
CompteRenduDto? result = await service.GetCompteRenduByRendezVousAsync(Guid.NewGuid());
|
|
|
|
Assert.Null(result);
|
|
Assert.Equal(0, client.CompteRenduCalls);
|
|
}
|
|
}
|