using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Interfaces.Mobile; using Omika.Data.Configuration; using Omika.Data.Connection; namespace Omika.Tests.E2E; /// /// Test E2E automatisé du pipeline de synchronisation multi-connexion réel. /// /// Ce test est SKIPPABLE : il n'écrit JAMAIS par défaut. Il exige /// explicitement, via la configuration locale non versionnée /// (config/hfsql-test.local.json), que les trois garde-fous soient /// simultanément vrais : /// /// E2E:AllowHfSqlWrites = true /// E2E:ServiceDatabaseConfirmedTest = true /// E2E:WebServiceDatabaseConfirmedTest = true /// /// Sinon, le test est SKIP (aucune écriture). /// /// Le test valide le pipeline réel : /// HTTP → auth (Bearer réellement validé par MobileAuthMiddleware) → /// SyncOperationService → repository → TOSRV (base Service) → /// Worker → MobReception (base WebService) → handler Intervention → /// Business → MobLog → status API. /// /// Limite documentée : ce test valide Bearer → middleware → endpoint Sync, /// mais NE valide PAS Licence → activate → token (la vraie base Licence de test /// n'est pas disponible ; BASE_LICENCES reste physiquement bloquée). /// public class SyncPipelineE2ETests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; // Délai d'attente raisonnable pour laisser le Worker externe traiter une // opération jusqu'à son résultat terminal. Ce n'est PAS une correction // d'idempotence : c'est un polling déterministe (pas un Task.Delay fixe // utilisé comme preuve de succès). private static readonly TimeSpan TerminalTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan PollingInterval = TimeSpan.FromMilliseconds(500); private readonly WebApplicationFactory _factory; public SyncPipelineE2ETests() { _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { // Charge la configuration locale HFSQL de test (non versionnée). // Les garde-fous E2E y sont lus. Les variables d'environnement // restent prioritaires. var configPath = HfSqlTestConfigurationLoader.ResolveConfigPath(); if (configPath is not null && File.Exists(configPath)) { config.AddJsonFile(configPath, optional: false, reloadOnChange: false); } // Clé de signature de TEST pour l'authentification mobile. // Jamais en source : elle vient de la configuration locale. config.AddInMemoryCollection(new Dictionary { ["MobileAuth:SigningKey"] = SigningKey }); }); }); } public void Dispose() { _factory.Dispose(); } /// /// Vérifie que les garde-fous E2E sont tous vrais avant toute écriture. /// Sinon, le test est SKIP (aucune écriture). /// private static E2EOptions LoadE2EOptions() { var configPath = HfSqlTestConfigurationLoader.ResolveConfigPath(); if (configPath is null || !File.Exists(configPath)) { return new E2EOptions(); } var config = new ConfigurationBuilder() .AddJsonFile(configPath, optional: false, reloadOnChange: false) .Build(); var options = new E2EOptions(); config.GetSection(E2EOptions.SectionName).Bind(options); return options; } /// /// Génère un token de test via le vrai TokenService (Bearer réellement /// validé par MobileAuthMiddleware). Aucun token hardcodé en source. /// private string CreateValidToken() { using var scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); return tokenService.GenerateToken(new MobileTokenIdentity { CodeClient = "E2E_TEST", DeviceId = "e2e-device-001", UserId = "e2e-user-001", CollaborateurId = "1", NumeroLicence = "e2e-licence-test" }); } /// /// Poll l'endpoint de statut jusqu'à un résultat terminal (SUCCESS ou /// REJECTED) ou jusqu'au timeout. Retourne la réponse terminale, ou /// si le timeout est atteint sans résultat terminal. /// /// Ce helper remplace tout Task.Delay fixe : il attend réellement le /// résultat terminal du Worker externe, ce qui est la seule preuve valide /// qu'une opération a été appliquée avant un éventuel replay. /// private async Task WaitForTerminalStatusAsync( HttpClient client, Guid operationId, TimeSpan timeout, TimeSpan pollingInterval, CancellationToken cancellationToken = default) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { var statusResponse = await client.GetAsync($"/api/sync/status/{operationId}", cancellationToken); if (statusResponse.StatusCode == HttpStatusCode.OK) { var status = await statusResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (status is not null && (status.Status == SyncStatus.Success || status.Status == SyncStatus.Rejected)) { return status; } } await Task.Delay(pollingInterval, cancellationToken); } return null; } /// /// Compte, en lecture seule, le nombre d'Interventions métier dont la /// référence correspond exactement à . /// /// Utilise la connexion WebService/Business (base qui héberge les données /// métier) via . Aucune écriture. /// private async Task CountInterventionsAsync( string reference, CancellationToken cancellationToken = default) { // Le scope contient WebServiceHfSqlConnection qui n'implémente que // IAsyncDisposable : on utilise une disposition asynchrone (CreateAsyncScope) // pour éviter l'InvalidOperationException de Dispose() synchrone. await using var scope = _factory.Services.CreateAsyncScope(); var connection = scope.ServiceProvider.GetRequiredService(); // La connexion doit être ouverte avant d'exécuter une commande. await connection.OpenAsync(cancellationToken); object? count = await connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM Intervention WHERE IN_RefInterne = ?", [reference], cancellationToken); return Convert.ToInt32(count); } /// /// Test E2E du pipeline complet : CREATE → TOSRV → MobReception → Business /// → MobLog → status API. /// /// SKIP si les garde-fous E2E ne sont pas tous vrais. /// [Fact] public async Task Create_Intervention_Pipeline_IsGreen() { var e2e = LoadE2EOptions(); if (!e2e.IsWriteAllowed) { // SKIP : aucune écriture sans consentement explicite. return; } // 1. Générer un token de test valide. string token = CreateValidToken(); var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); // 2. Construire une opération CREATE Intervention avec des données TEST. var operationId = Guid.NewGuid(); var requestedEntityGuid = Guid.NewGuid(); string reference = $"E2E_TEST_{operationId.ToString("N")[..8]}"; var operation = new SyncOperation { OperationId = operationId, DeviceId = "e2e-device-001", EntityType = SyncEntityTypes.Intervention, OperationType = SyncOperationType.Create, EntityGuid = requestedEntityGuid, Payload = JsonSerializer.Serialize(new { Reference = reference, Titre = "Intervention E2E TEST", Description = "Données de test E2E - aucune donnée personnelle réelle." }) }; // 3. Envoyer via POST /api/sync/operations. var response = await client.PostAsJsonAsync("/api/sync/operations", operation); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var receipt = await response.Content.ReadFromJsonAsync(); Assert.NotNull(receipt); Assert.Equal("RECEIVED", receipt.Status); Assert.Equal(operationId, receipt.OperationId); // 4. Poller le statut jusqu'au résultat terminal SUCCESS (le Worker // externe doit avoir traité l'opération). On exige réellement SUCCESS, // pas un simple GET immédiat qui pourrait être encore Pending. SyncStatusResponse? status = await WaitForTerminalStatusAsync( client, operationId, TerminalTimeout, PollingInterval); Assert.NotNull(status); Assert.Equal(operationId, status!.OperationId); Assert.Equal(SyncStatus.Success, status.Status); // 5. Vérifier qu'exactement UNE Intervention métier a été créée. int count = await CountInterventionsAsync(reference); Assert.Equal(1, count); } /// /// Test E2E du replay : réenvoyer exactement le même OperationId, le même /// RequestedEntityGuid et le même payload APRÈS que le premier POST a atteint /// SUCCESS. Attendu : aucune deuxième Intervention métier ; résultat terminal /// réutilisé ; même CanonicalEntityGuid ; status toujours SUCCESS. /// /// Ce test est AUTONOME : il génère son propre OperationId et ne dépend /// d'aucun autre test xUnit (pas d'ordre d'exécution implicite). /// /// SKIP si les garde-fous E2E ne sont pas tous vrais. /// [Fact] public async Task Replay_SameOperationId_IsIdempotent() { var e2e = LoadE2EOptions(); if (!e2e.IsWriteAllowed) { // SKIP : aucune écriture sans consentement explicite. return; } string token = CreateValidToken(); var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); // 1. Générer un scénario autonome : OperationId X, RequestedEntityGuid X, // payload P (référence dérivée de X). Préfixe E2E_TX_ pour distinguer // cette validation des anciennes lignes E2E_TEST_* (traces historiques). var operationId = Guid.NewGuid(); var requestedEntityGuid = Guid.NewGuid(); string reference = $"E2E_TX_{operationId.ToString("N")[..8]}"; var operation = new SyncOperation { OperationId = operationId, DeviceId = "e2e-device-001", EntityType = SyncEntityTypes.Intervention, OperationType = SyncOperationType.Create, EntityGuid = requestedEntityGuid, Payload = JsonSerializer.Serialize(new { Reference = reference, Titre = "Intervention E2E TEST (replay)", Description = "Données de test E2E - aucune donnée personnelle réelle." }) }; // 2. Premier POST : CREATE X/P. var first = await client.PostAsJsonAsync("/api/sync/operations", operation); Assert.Equal(HttpStatusCode.OK, first.StatusCode); // 3. Attendre réellement le SUCCESS terminal du premier POST avant tout // replay. Sinon, ce ne serait pas un test d'idempotence terminale. SyncStatusResponse? firstStatus = await WaitForTerminalStatusAsync( client, operationId, TerminalTimeout, PollingInterval); Assert.NotNull(firstStatus); Assert.Equal(SyncStatus.Success, firstStatus!.Status); // 4. Compter les Interventions métier : exactement 1 après le premier POST. int countAfterFirst = await CountInterventionsAsync(reference); Assert.Equal(1, countAfterFirst); // 5. Re-POST EXACTEMENT le même X/P (même OperationId, même // RequestedEntityGuid, même payload). var second = await client.PostAsJsonAsync("/api/sync/operations", operation); Assert.Equal(HttpStatusCode.OK, second.StatusCode); var receipt = await second.Content.ReadFromJsonAsync(); Assert.NotNull(receipt); Assert.Equal("RECEIVED", receipt.Status); Assert.Equal(operationId, receipt.OperationId); // 6. Attendre le résultat terminal du replay : SUCCESS, avec le même // CanonicalEntityGuid que le premier POST (résultat réutilisé, pas // réappliqué). SyncStatusResponse? replayStatus = await WaitForTerminalStatusAsync( client, operationId, TerminalTimeout, PollingInterval); Assert.NotNull(replayStatus); Assert.Equal(SyncStatus.Success, replayStatus!.Status); Assert.Equal(firstStatus.CanonicalEntityGuid, replayStatus.CanonicalEntityGuid); // 7. Vérifier que le nombre d'Interventions métier est TOUJOURS 1 : // le replay n'a pas créé de doublon. int countAfterReplay = await CountInterventionsAsync(reference); Assert.Equal(1, countAfterReplay); } }