341 lines
14 KiB
C#
341 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Test E2E automatisé du pipeline de synchronisation multi-connexion réel.
|
|
///
|
|
/// Ce test est <b>SKIPPABLE</b> : il n'écrit JAMAIS par défaut. Il exige
|
|
/// explicitement, via la configuration locale non versionnée
|
|
/// (<c>config/hfsql-test.local.json</c>), que les trois garde-fous soient
|
|
/// simultanément vrais :
|
|
/// <list type="bullet">
|
|
/// <item><c>E2E:AllowHfSqlWrites = true</c></item>
|
|
/// <item><c>E2E:ServiceDatabaseConfirmedTest = true</c></item>
|
|
/// <item><c>E2E:WebServiceDatabaseConfirmedTest = true</c></item>
|
|
/// </list>
|
|
/// 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).
|
|
/// </summary>
|
|
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<Program> _factory;
|
|
|
|
public SyncPipelineE2ETests()
|
|
{
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.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<string, string?>
|
|
{
|
|
["MobileAuth:SigningKey"] = SigningKey
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_factory.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vérifie que les garde-fous E2E sont tous vrais avant toute écriture.
|
|
/// Sinon, le test est SKIP (aucune écriture).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Génère un token de test via le vrai TokenService (Bearer réellement
|
|
/// validé par MobileAuthMiddleware). Aucun token hardcodé en source.
|
|
/// </summary>
|
|
private string CreateValidToken()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
|
|
return tokenService.GenerateToken(new MobileTokenIdentity
|
|
{
|
|
CodeClient = "E2E_TEST",
|
|
DeviceId = "e2e-device-001",
|
|
UserId = "e2e-user-001",
|
|
CollaborateurId = "1",
|
|
NumeroLicence = "e2e-licence-test"
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Poll l'endpoint de statut jusqu'à un résultat terminal (SUCCESS ou
|
|
/// REJECTED) ou jusqu'au timeout. Retourne la réponse terminale, ou
|
|
/// <see langword="null"/> si le timeout est atteint sans résultat terminal.
|
|
///
|
|
/// Ce helper remplace tout <c>Task.Delay</c> 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.
|
|
/// </summary>
|
|
private async Task<SyncStatusResponse?> 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<SyncStatusResponse>(cancellationToken: cancellationToken);
|
|
if (status is not null
|
|
&& (status.Status == SyncStatus.Success || status.Status == SyncStatus.Rejected))
|
|
{
|
|
return status;
|
|
}
|
|
}
|
|
|
|
await Task.Delay(pollingInterval, cancellationToken);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compte, en lecture seule, le nombre d'Interventions métier dont la
|
|
/// référence correspond exactement à <paramref name="reference"/>.
|
|
///
|
|
/// Utilise la connexion WebService/Business (base qui héberge les données
|
|
/// métier) via <see cref="IWebServiceHfSqlConnection"/>. Aucune écriture.
|
|
/// </summary>
|
|
private async Task<int> 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<IWebServiceHfSqlConnection>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test E2E du pipeline complet : CREATE → TOSRV → MobReception → Business
|
|
/// → MobLog → status API.
|
|
///
|
|
/// SKIP si les garde-fous E2E ne sont pas tous vrais.
|
|
/// </summary>
|
|
[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<SyncOperationReceipt>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<SyncOperationReceipt>();
|
|
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);
|
|
}
|
|
}
|