Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user