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