using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using Omika.App.Local.Services; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Bootstrap; using Omika.Core.Contracts.Mobile.License; namespace Omika.Tests.Mobile.Local; /// /// Tests du MobileApiClient : activation, validation, bootstrap authentifié et /// gestion des réponses d'erreur. Vérifie que le Bearer token est bien envoyé /// sur les requêtes authentifiées et jamais loggé. /// public class MobileApiClientTests { private sealed class FakeHandler : HttpMessageHandler { public Func Responder { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.OK); public HttpRequestMessage? LastRequest { get; private set; } public string? LastRequestBody { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; if (request.Content != null) { LastRequestBody = request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult(); } return Task.FromResult(Responder(request)); } } private static MobileApiClient CreateClient(FakeHandler handler, string baseUrl = "http://test.local") { var http = new HttpClient(handler); return new MobileApiClient(http) { BaseUrl = baseUrl }; } private static HttpResponseMessage JsonResponse(HttpStatusCode status, object body) { string json = JsonSerializer.Serialize(body, new JsonSerializerOptions(JsonSerializerDefaults.Web)); return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; } [Fact] public async Task Activate_EnvoieRequeteEtDeserialise() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = true, CodeClient = "CLIENT01" }, AccessToken = "token-abc", AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24) }) }; var client = CreateClient(handler); // Act var result = await client.ActivateAsync(new LicenseActivateRequest { CodeClient = "CLIENT01", NumeroLicence = "LIC-001", DeviceId = "device-1" }); // Assert Assert.True(result.License.IsValid); Assert.Equal("token-abc", result.AccessToken); Assert.Equal("/api/license/activate", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.Contains("\"codeClient\":\"CLIENT01\"", handler.LastRequestBody); Assert.Contains("\"deviceId\":\"device-1\"", handler.LastRequestBody); } [Fact] public async Task Activate_Reponse400_DeserialiseErreur() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.BadRequest, new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = false, ErrorCode = "E1001", ErrorMessage = "Licence inconnue" } }) }; var client = CreateClient(handler); // Act var result = await client.ActivateAsync(new LicenseActivateRequest { CodeClient = "CLIENT01", NumeroLicence = "LIC-999" }); // Assert Assert.False(result.License.IsValid); Assert.Equal("E1001", result.License.ErrorCode); Assert.Equal("Licence inconnue", result.License.ErrorMessage); } [Fact] public async Task Validate_EnvoieRequete() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = true }, AccessToken = "token-validate" }) }; var client = CreateClient(handler); // Act var result = await client.ValidateAsync(new LicenseValidateRequest { CodeClient = "CLIENT01", NumeroLicence = "LIC-001", DeviceId = "device-1" }); // Assert Assert.True(result.License.IsValid); Assert.Equal("/api/license/validate", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.Contains("\"codeClient\":\"CLIENT01\"", handler.LastRequestBody); } [Fact] public async Task Bootstrap_AvecBearerToken_EnvoieAuthorization() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new MobileBootstrapResponse { Utilisateur = new Omika.Core.Contracts.Security.CurrentUserDto { NomAffiche = "Dupont" } }) }; var client = CreateClient(handler); client.AccessToken = "token-secret"; // Act var result = await client.GetBootstrapAsync(); // Assert : le Bearer token est envoyé. Assert.Equal("/api/mobile/bootstrap", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.Equal("Bearer token-secret", handler.LastRequest.Headers.Authorization!.ToString()); Assert.Equal("Dupont", result.Utilisateur.NomAffiche); } [Fact] public async Task Bootstrap_SansToken_PasDeAuthorization() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new MobileBootstrapResponse()) }; var client = CreateClient(handler); // Act await client.GetBootstrapAsync(); // Assert : pas d'en-tête Authorization sans token. Assert.Null(handler.LastRequest!.Headers.Authorization); } [Fact] public async Task Bootstrap_ErreurServeur_LeveException() { // Arrange var handler = new FakeHandler { Responder = _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) }; var client = CreateClient(handler); client.AccessToken = "token-expire"; // Act / Assert await Assert.ThrowsAsync(() => client.GetBootstrapAsync()); } [Fact] public async Task Snapshot_AvecBearerToken_EnvoieAuthorization() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new Omika.Core.Contracts.Mobile.Snapshot.MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow, Clients = [ new Omika.Core.Contracts.Clients.ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Client 1" } ] }) }; var client = CreateClient(handler); client.AccessToken = "token-snapshot"; // Act var result = await client.GetSnapshotAsync(); // Assert : le Bearer token est envoyé sur /api/sync/snapshot et la // réponse est correctement désérialisée. Assert.Equal("/api/sync/snapshot", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.Equal("Bearer token-snapshot", handler.LastRequest.Headers.Authorization!.ToString()); Assert.NotEqual(default, result.GeneratedAtUtc); Assert.Single(result.Clients); Assert.Equal("Client 1", result.Clients[0].Nom); } [Fact] public async Task Snapshot_ErreurServeur_LeveException() { // Arrange var handler = new FakeHandler { Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) }; var client = CreateClient(handler); client.AccessToken = "token-expire"; // Act / Assert : une erreur serveur lève une exception (l'ancienne base // locale reste utilisable, gérée par le SyncEngine). await Assert.ThrowsAsync(() => client.GetSnapshotAsync()); } [Fact] public async Task Snapshot_DeserialisationEchoue_LeveException() { // Arrange : réponse HTTP 200 mais corps JSON syntaxiquement invalide. var handler = new FakeHandler { Responder = _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"generatedAtUtc": "not-a-date", """, Encoding.UTF8, "application/json") } }; var client = CreateClient(handler); client.AccessToken = "token-snapshot"; // Act / Assert : la désérialisation échoue → JsonException. Le SyncEngine // capture cette exception et conserve l'ancienne base locale. await Assert.ThrowsAsync(() => client.GetSnapshotAsync()); } [Fact] public async Task DevelopmentSession_EnvoieRequete() { // Arrange var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.OK, new MobileLicenseSessionResponse { License = new LicenseResponse { IsValid = true, CodeClient = "LOCAL-DEV" }, AccessToken = "token-dev" }) }; var client = CreateClient(handler); // Act var result = await client.GetDevelopmentSessionAsync("device-test-1"); // Assert : l'endpoint Development est appelé et la session est désérialisée. Assert.Equal("/api/dev/session", handler.LastRequest!.RequestUri!.AbsolutePath); Assert.True(result.License.IsValid); Assert.Equal("token-dev", result.AccessToken); } // ========================================================================= // Envoi d'email : capture du message serveur (au lieu du message technique // « Response status code does not indicate success: 500 »). // ========================================================================= [Fact] public async Task SendEmail_Erreur500_AvecBodyJson_LeveMessageServeur() { // Arrange : le serveur renvoie un 500 structuré (message utilisateur + // détail technique). Le client doit exposer le message, pas le code 500. var handler = new FakeHandler { Responder = _ => JsonResponse(HttpStatusCode.InternalServerError, new { message = "Le serveur de messagerie a refusé l'envoi.", detail = "5.7.1 relay denied", type = "EnvoiRefuse" }) }; var client = CreateClient(handler); // Act InvalidOperationException ex = await Assert.ThrowsAsync(() => client.SendEmailByInterventionAsync( Guid.NewGuid(), null, new CreateEmailInterventionRequest { Objet = "Test" })); // Assert Assert.Equal("Le serveur de messagerie a refusé l'envoi.", ex.Message); } [Fact] public async Task SendEmail_Erreur400_TexteBrut_LeveTexteServeur() { // Arrange : le serveur renvoie un 400 texte brut (ex. « Sélectionnez une // configuration SMTP... »). var handler = new FakeHandler { Responder = _ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("Sélectionnez une configuration SMTP pour envoyer le courriel.", Encoding.UTF8, "text/plain") } }; var client = CreateClient(handler); // Act InvalidOperationException ex = await Assert.ThrowsAsync(() => client.SendEmailByInterventionAsync( Guid.NewGuid(), null, new CreateEmailInterventionRequest { Objet = "Test" })); // Assert Assert.Equal("Sélectionnez une configuration SMTP pour envoyer le courriel.", ex.Message); } [Fact] public async Task SendEmail_Erreur500_SansBody_LeveMessageGenerique() { // Arrange : 500 sans corps (serveur sans gestionnaire d'erreur). var handler = new FakeHandler { Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) }; var client = CreateClient(handler); // Act InvalidOperationException ex = await Assert.ThrowsAsync(() => client.SendEmailByInterventionAsync( Guid.NewGuid(), null, new CreateEmailInterventionRequest { Objet = "Test" })); // Assert Assert.Equal("Impossible d'envoyer l'email.", ex.Message); } [Fact] public void CreateHttpClient_BorneLeDelaiDesRequetes() { // Le défaut de HttpClient (100 s) laisserait l'écran en chargement plus d'une // minute sur un réseau mobile dégradé, avant un échec de toute façon inévitable. using HttpClient client = MobileApiClient.CreateHttpClient(new FakeHandler()); Assert.Equal(MobileApiClient.DefaultTimeout, client.Timeout); Assert.True( client.Timeout <= TimeSpan.FromSeconds(30), $"Délai trop long pour un client mobile : {client.Timeout}"); Assert.True( client.Timeout >= TimeSpan.FromSeconds(5), $"Délai trop court pour un réseau mobile : {client.Timeout}"); } }