Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,544 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Core.Configuration;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Data.Connection;
using Omika.Worker;
namespace Omika.Tests.Mobile;
/// <summary>
/// Tâche 13-E — instrumentation du Worker métier (SyncProcessor) dans le pipeline
/// Diagnostics. Vérifie l'émission réelle de :
/// <list type="bullet">
/// <item>OMK-WKR-SYNC-001 (succès, Info/P3) ;</item>
/// <item>OMK-WKR-SYNC-002 (échec définitif, Error/P1) ;</item>
/// <item>contexte ClientCode/ServerInstance/Environment toujours issu du provider
/// (jamais de l'opération/payload) ;</item>
/// <item>fail-open absolu (provider sans ClientCode, store en panne) ;</item>
/// <item>corrélation par OperationId (aucun faux TraceId inventé).</item>
/// </list>
/// Aucune base HFSQL/PostgreSQL réelle : fakes en mémoire uniquement.
/// </summary>
public class WorkerSyncDiagnosticsTests
{
private static SyncOptions DefaultOptions() => new()
{
MaxRetryCount = 30,
RetryDelaySeconds = 120,
LogRetentionDays = 30
};
private static SyncEnvelope CreateEnvelope(
SyncOperationType type,
Guid? operationId = null,
int retryCount = 0,
string? payload = null) => new()
{
OperationId = operationId ?? Guid.NewGuid(),
DeviceId = "device-1",
ClientId = "FAKE-CLIENT-ID",
UserId = "user-1",
CollaborateurId = "collab-1",
EntityType = "Client",
OperationType = type,
EntityGuid = Guid.NewGuid(),
RetryCount = retryCount,
Payload = payload ?? "{}",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
private static FakeDeploymentContextProvider Provider(
string? clientCode = "CLIENT-A",
string serverInstance = "WKR-SRV-01",
string environment = "Production") =>
new() { Context = new DiagnosticDeploymentContext { ClientCode = clientCode, ServerInstance = serverInstance, Environment = environment } };
private static SyncProcessor CreateProcessor(
FakeSyncRepository repo,
ISyncOperationHandler handler,
SyncOptions? options = null,
IWebServiceTransactionContext? transactionContext = null,
IRemoteDiagnosticIngestionStore? store = null,
IDiagnosticDeploymentContextProvider? provider = null)
{
var registry = new SyncOperationHandlerRegistry(new[] { handler });
return new SyncProcessor(
repo,
registry,
new DefaultSyncOperationHandler(),
options ?? DefaultOptions(),
NullLogger<SyncProcessor>.Instance,
transactionContext ?? new FakeTransaction(),
new WorkerDiagnosticEmitter(
store ?? new FakeDiagnosticStore(),
provider ?? Provider()));
}
// --------------------------- E1 : succès -----------------------------------
[Fact]
public async Task E1_Success_EmitsWorkerSyncProcessed_WithDeploymentContext()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var provider = Provider();
var processor = CreateProcessor(repo, handler, store: store, provider: provider);
var envelope = CreateEnvelope(SyncOperationType.Create);
bool handled = await processor.ProcessOperationAsync(envelope);
Assert.True(handled);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-001", evt.EventCode);
Assert.Equal("Info", evt.Level);
Assert.Equal("P3", evt.Priority);
Assert.Equal(SyncProcessor.DiagnosticComponent, evt.Component);
Assert.Equal("CLIENT-A", call.Context!.ClientCode);
Assert.Equal("WKR-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- E2 : échec définitif --------------------------
[Fact]
public async Task E2_PermanentFailure_EmitsWorkerSyncFailed_ErrorP1()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.PermanentFailure("NO_HANDLER", "Aucun handler"));
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Create);
bool handled = await processor.ProcessOperationAsync(envelope);
Assert.True(handled);
Assert.Contains(envelope.OperationId, repo.Rejected); // mécanisme métier intact.
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode);
Assert.Equal("Error", evt.Level);
Assert.Equal("P1", evt.Priority);
Assert.Equal("NO_HANDLER", evt.ErrorCode);
Assert.False(evt.Success);
Assert.Equal("CLIENT-A", call.Context!.ClientCode);
Assert.Equal("WKR-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- E3 : fail-open provider -----------------------
[Fact]
public async Task E3_ProviderWithoutClientCode_EventStillWrittenWithNull()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider(clientCode: null));
var envelope = CreateEnvelope(SyncOperationType.Create);
bool handled = await processor.ProcessOperationAsync(envelope);
Assert.True(handled);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
Assert.Null(call.Context!.ClientCode);
Assert.Equal("WKR-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- E4 : store en panne ---------------------------
[Fact]
public async Task E4_StoreFailure_DoesNotBreakBusinessProcessing()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore(throwOnStore: true);
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Create);
bool handled = await processor.ProcessOperationAsync(envelope);
// Fail-open : la panne du store n'a pas transformé le succès métier en échec.
Assert.True(handled);
Assert.Contains(envelope.OperationId, repo.Succeeded);
}
// --------------------------- E5 : contexte non falsifiable -----------------
[Fact]
public async Task E5_OperationIdentities_NeverReplaceProviderClientCode()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider(clientCode: "CLIENT-A"));
var envelope = CreateEnvelope(SyncOperationType.Create);
await processor.ProcessOperationAsync(envelope);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
Assert.Equal("CLIENT-A", call.Context!.ClientCode);
string json = JsonSerializer.Serialize(call.Batch);
Assert.DoesNotContain("FAKE-CLIENT-ID", json, StringComparison.Ordinal);
Assert.DoesNotContain("device-1", json, StringComparison.Ordinal);
Assert.DoesNotContain("user-1", json, StringComparison.Ordinal);
Assert.DoesNotContain("collab-1", json, StringComparison.Ordinal);
}
// --------------------------- E6 : ServerInstance Worker --------------------
[Fact]
public async Task E6_ServerInstance_ComesFromProvider_NotOtherComponents()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider(serverInstance: "WKR-SRV-01"));
await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create));
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
Assert.Equal("WKR-SRV-01", call.Context!.ServerInstance);
Assert.NotEqual("API-SRV-01", call.Context.ServerInstance);
}
// --------------------------- E7 : Environment Worker -----------------------
[Fact]
public async Task E7_Environment_ComesFromProvider()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider(environment: "Staging"));
await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create));
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
Assert.Equal("Staging", call.Context!.Environment);
}
// --------------------------- E8 : corrélation ------------------------------
[Fact]
public async Task E8_OperationIdPreserved_NoFakeTraceIdInvented()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Create);
await processor.ProcessOperationAsync(envelope);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
// Corrélation réelle : OperationId transporté de bout en bout (mobile → API
// → TOSRV → Worker). Aucun TraceId n'est transporté (SyncEnvelope n'en porte
// pas) : on n'en invente PAS un faux.
Assert.Equal(envelope.OperationId, evt.OperationId);
Assert.Null(evt.TraceId);
Assert.Null(evt.SessionId);
}
// --------------------------- E9 : concurrence ------------------------------
[Fact]
public async Task E9_ConcurrentOperations_DoNotContaminateCorrelation()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelopeA = CreateEnvelope(SyncOperationType.Create);
var envelopeB = CreateEnvelope(SyncOperationType.Update);
var envelopeC = CreateEnvelope(SyncOperationType.Delete);
await Task.WhenAll(
processor.ProcessOperationAsync(envelopeA),
processor.ProcessOperationAsync(envelopeB),
processor.ProcessOperationAsync(envelopeC));
Assert.Equal(3, store.Calls.Count);
var operationIds = store.Calls.Select(c => Assert.Single(c.Batch.Events).OperationId).ToArray();
Assert.Contains(envelopeA.OperationId, operationIds);
Assert.Contains(envelopeB.OperationId, operationIds);
Assert.Contains(envelopeC.OperationId, operationIds);
Assert.Equal(3, operationIds.Distinct().Count());
// Le contexte de déploiement est commun (instance = 1 client) : valeurs stables.
Assert.All(store.Calls, c => Assert.Equal("WKR-SRV-01", c.Context!.ServerInstance));
Assert.All(store.Calls, c => Assert.Equal("Production", c.Context!.Environment));
}
// --------------------------- E10 : absence de secrets ----------------------
[Fact]
public async Task E10_NoSecrets_LeakIntoProducedEvents()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
// Payload métier volontairement truffé de valeurs sensibles : il ne doit
// JAMAIS être copié dans l'événement Diagnostics produit.
string payload = """
{"password":"S3CR3T","token":"Bearer eyJhbGciOiJIUzI1NiJ9","hmacKey":"h0000","connectionString":"Server=x;Password=y","CL_CleAPIInstall":"CLE-SECRET"}
""";
await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create, payload: payload));
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
string json = JsonSerializer.Serialize(call.Batch);
string contextJson = JsonSerializer.Serialize(call.Context);
Assert.DoesNotContain("S3CR3T", json, StringComparison.Ordinal);
Assert.DoesNotContain("Bearer", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("eyJhbGciOi", json, StringComparison.Ordinal);
Assert.DoesNotContain("hmacKey", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("connectionString", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("CL_CleAPIInstall", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("CLE-SECRET", json, StringComparison.Ordinal);
Assert.DoesNotContain("CLE-SECRET", contextJson, StringComparison.Ordinal);
}
// --------------------------- E11 : AppVersion ------------------------------
[Fact]
public async Task E11_AppVersion_UsesExistingConvention_NotHardcoded()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.Success());
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
await processor.ProcessOperationAsync(CreateEnvelope(SyncOperationType.Create));
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
// Convention Diagnostics existante : ApplicationConstants.Version (source de
// vérité unique de la solution), jamais une chaîne "1.0.0.0" en dur ici.
Assert.Equal(ApplicationConstants.Version, evt.AppVersion);
}
// --------------------------- Retry : échec intermédiaire → succès final ----
[Fact]
public async Task Retry_IntermediateFailure_ThenSuccess_EmitsTransientWarningThenSuccess()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Create);
// Tentative 1 : échec temporaire → retry, UN événement Warning/P2 (retry visible).
bool first = await processor.ProcessOperationAsync(envelope);
Assert.False(first);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) transientCall = Assert.Single(store.Calls);
DiagnosticEventDto transientEvt = Assert.Single(transientCall.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-002", transientEvt.EventCode);
Assert.Equal("Warning", transientEvt.Level);
Assert.Equal("P2", transientEvt.Priority);
Assert.False(transientEvt.Success);
Assert.Equal(envelope.OperationId, transientEvt.OperationId); // OperationId stable.
// Tentative 2 : succès → un DEUXIÈME événement 001 (Info/P3).
handler.Result = SyncOperationResult.Success();
var envelopeRetry = envelope with { RetryCount = 1 };
bool second = await processor.ProcessOperationAsync(envelopeRetry);
Assert.True(second);
Assert.Equal(2, store.Calls.Count);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) successCall = store.Calls[1];
DiagnosticEventDto successEvt = Assert.Single(successCall.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-001", successEvt.EventCode);
Assert.Equal("Info", successEvt.Level);
Assert.Equal("P3", successEvt.Priority);
Assert.Equal(envelope.OperationId, successEvt.OperationId); // OperationId stable.
}
// --------------------------- Retry : échec définitif après max -------------
[Fact]
public async Task Retry_MaxRetry_EmitsSingleFailedEvent()
{
var repo = new FakeSyncRepository();
var handler = new FakeHandler(SyncOperationResult.TransientFailure("DB_BUSY"));
var options = DefaultOptions();
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, options: options, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Delete, retryCount: options.MaxRetryCount);
bool handled = await processor.ProcessOperationAsync(envelope);
Assert.True(handled);
Assert.Contains(envelope.OperationId, repo.Rejected);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode);
Assert.Equal("MAX_RETRY", evt.ErrorCode);
Assert.Equal(envelope.OperationId, evt.OperationId);
}
// --------------------------- E12 : exception transitoire (détail) ----------
[Fact]
public async Task E12_TransientException_EmitsWarning_WithExceptionTypeAndStackTrace()
{
var repo = new FakeSyncRepository();
var handler = new ThrowingHandler(new InvalidOperationException("DB indisponible (simulé)"));
var store = new FakeDiagnosticStore();
var processor = CreateProcessor(repo, handler, store: store, provider: Provider());
var envelope = CreateEnvelope(SyncOperationType.Create);
bool handled = await processor.ProcessOperationAsync(envelope);
Assert.False(handled);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(store.Calls);
DiagnosticEventDto evt = Assert.Single(call.Batch.Events);
Assert.Equal("OMK-WKR-SYNC-002", evt.EventCode);
Assert.Equal("Warning", evt.Level);
Assert.Equal("P2", evt.Priority);
Assert.False(evt.Success);
Assert.Equal("UNEXPECTED", evt.ErrorCode);
Assert.Equal("System.InvalidOperationException", evt.ExceptionType);
Assert.NotNull(evt.StackTrace);
Assert.Contains("DB indisponible", evt.Message, StringComparison.Ordinal);
Assert.Equal(envelope.OperationId, evt.OperationId);
}
// ===== Fakes ===============================================================
private sealed class FakeDiagnosticStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = [];
private readonly bool _throwOnStore;
public FakeDiagnosticStore(bool throwOnStore = false) => _throwOnStore = throwOnStore;
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls
{
get { lock (_gate) { return _calls.ToArray(); } }
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
if (_throwOnStore)
{
throw new InvalidOperationException("Store en panne (simulé).");
}
lock (_gate)
{
_calls.Add((batch, deploymentContext));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeSyncRepository : ISyncTransferRepository
{
private readonly object _gate = new();
private readonly List<Guid> _succeeded = [];
private readonly List<Guid> _rejected = [];
public IReadOnlyList<Guid> Succeeded { get { lock (_gate) { return _succeeded.ToArray(); } } }
public IReadOnlyList<Guid> Rejected { get { lock (_gate) { return _rejected.ToArray(); } } }
public Task<bool> ExistsAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(Succeeded.Contains(operationId) || Rejected.Contains(operationId));
public Task<SyncOperationState> GetExistingStateAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult(SyncOperationState.Unknown);
public Task InsertToSrvAsync(SyncEnvelope envelope, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<SyncStatusResponse?> GetStatusAsync(Guid operationId, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncStatusResponse?>(null);
public Task<SyncLogEntry?> GetTerminalResultAsync(Guid operationId, CancellationToken cancellationToken = default)
=> Task.FromResult<SyncLogEntry?>(null);
public Task WriteLogAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<int> PurgeLogsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> TransferToReceptionAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<SyncEnvelope>> GetPendingOperationsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SyncEnvelope>>([]);
public Task MarkSuccessAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
{
lock (_gate) { _succeeded.Add(entry.OperationId); }
return Task.CompletedTask;
}
public Task MarkRejectedAsync(SyncLogEntry entry, CancellationToken cancellationToken = default)
{
lock (_gate) { _rejected.Add(entry.OperationId); }
return Task.CompletedTask;
}
public Task IncrementRetryAsync(Guid operationId, int newRetryCount, DateTime nextAttemptUtc, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class FakeHandler : ISyncOperationHandler
{
public FakeHandler(SyncOperationResult result) => Result = result;
public SyncOperationResult Result { get; set; }
public IReadOnlyCollection<string> SupportedEntityTypes => ["Client"];
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
private sealed class ThrowingHandler : ISyncOperationHandler
{
private readonly Exception _exception;
public ThrowingHandler(Exception exception) => _exception = exception;
public IReadOnlyCollection<string> SupportedEntityTypes => ["Client"];
public Task<SyncOperationResult> ApplyAsync(SyncOperation operation, CancellationToken cancellationToken = default)
=> throw _exception;
}
private sealed class FakeTransaction : IWebServiceTransactionContext
{
public IHfsqlWriteConnection Connection => throw new NotSupportedException("Le fake de transaction n'expose pas de connexion physique.");
public Task BeginAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RollbackAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}