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;
///
/// 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.
///
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 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? 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 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 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 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 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 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 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 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(local!.PayloadJson)?.Reference);
// Assert : une opération Outbox CREATE est enqueue (RequestedEntityGuid = A).
IReadOnlyList 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(local!.PayloadJson)?.Titre);
// Assert : une opération Outbox UPDATE est enqueue.
IReadOnlyList 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(local!.PayloadJson)?.EstArchive);
// Assert : une opération Outbox ARCHIVE est enqueue.
IReadOnlyList 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 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 =====
///
/// 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 : GetCompteRenduByRendezVousAsync
/// est une implémentation par défaut de , et une classe
/// dérivée ne peut la remplacer qu'en réimplémentant explicitement l'interface.
///
private sealed class CompteRenduApiClient : StubMobileApiClient, IMobileApiClient
{
public int CompteRenduCalls { get; private set; }
public Exception? ExceptionToThrow { get; set; }
public CompteRenduDto? Result { get; set; }
public Task GetCompteRenduByRendezVousAsync(
Guid rendezVousGuid,
CancellationToken cancellationToken = default)
{
CompteRenduCalls++;
return ExceptionToThrow is { } ex
? Task.FromException(ex)
: Task.FromResult(Result);
}
}
/// Service réel câblé sur le client API et la connectivité fournis.
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);
}
/// Pose la copie locale d'un compte rendu (dernier état connu).
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
});
}
///
/// 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.
///
[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);
}
///
/// É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éé).
///
[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);
}
///
/// Hors ligne, l'appel réseau n'est pas tenté du tout (aucune latence d'échec) :
/// la copie locale est retournée directement.
///
[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);
}
///
/// 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.
///
[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);
}
}