Files
Omika/tests/Omika.Tests/Observability/Outbox/OutboxTestHarness.cs
T
2026-10-23 18:46:50 +02:00

215 lines
6.7 KiB
C#

using System.Text;
using Omika.Core.Observability;
using Omika.Core.Observability.Outbox;
namespace Omika.Tests.Observability.Outbox;
/// <summary>Fabrique de test partagée pour le diagnostic distant (Phase 4).</summary>
internal static class OutboxTestHarness
{
public static TelemetryEvent NewEvent(
TelemetryPriority priority = TelemetryPriority.P3,
TelemetryLevel level = TelemetryLevel.Info,
EventCode? code = null,
Guid? eventId = null,
TelemetryMetadata? metadata = null) =>
new()
{
EventId = eventId ?? Guid.NewGuid(),
EventCode = code ?? TelemetryEventCodes.MobileAppStarted,
Level = level,
Priority = priority,
Metadata = metadata
};
/// <summary>
/// Lit un fichier en cohabitation avec un writer encore actif. Les exporteurs
/// (LocalFileTelemetryExporter / RemoteDiagnosticIngestionStore) ouvrent en
/// <c>FileAccess.Write, FileShare.Read</c> (un writer, lectures autorisées) : un
/// lecteur doit donc ouvrir avec <c>FileShare.ReadWrite</c> — jamais
/// <see cref="File.ReadAllText"/> / <see cref="File.ReadAllLines"/> dont le
/// <c>FileShare.Read</c> rejette l'écriture concurrente.
/// </summary>
public static string ReadAllTextShared(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
/// <summary>Équivalent lignes de <see cref="ReadAllTextShared"/> (UTF-8, partage d'écriture).</summary>
public static string[] ReadAllLinesShared(string path)
{
var lines = new List<string>();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (reader.ReadLine() is { } line)
{
lines.Add(line);
}
return lines.ToArray();
}
}
/// <summary>
/// Store en mémoire pour les tests Core (aucune dépendance SQLite). Permet
/// d'injecter une panne (ThrowOn) pour vérifier le fail-open de l'outbox.
/// </summary>
internal sealed class InMemoryDiagnosticOutboxStore : IDiagnosticOutboxStore
{
private readonly List<DiagnosticOutboxRow> _rows = [];
public bool ThrowOnStore { get; set; }
public bool ThrowOnCount { get; set; }
public Task StoreAsync(IReadOnlyList<DiagnosticOutboxRow> rows, CancellationToken cancellationToken = default)
{
if (ThrowOnStore)
{
throw new InvalidOperationException("store down");
}
lock (_rows)
{
foreach (DiagnosticOutboxRow row in rows)
{
_rows.RemoveAll(r => r.EventId == row.EventId);
_rows.Add(row);
}
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<DiagnosticOutboxRow>> GetNextBatchAsync(
int maxCount,
long maxBytes,
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
lock (_rows)
{
var result = new List<DiagnosticOutboxRow>();
long bytes = 0;
foreach (DiagnosticOutboxRow row in _rows
.Where(r => r.NextAttemptUtc <= now)
.OrderBy(r => r.Priority)
.ThenBy(r => r.CreatedAtUtc))
{
long rowBytes = Encoding.UTF8.GetByteCount(row.PayloadJson);
if (result.Count > 0 && bytes + rowBytes > maxBytes)
{
break;
}
result.Add(row);
bytes += rowBytes;
if (result.Count >= maxCount)
{
break;
}
}
return Task.FromResult<IReadOnlyList<DiagnosticOutboxRow>>(result);
}
}
public Task DeleteAsync(IReadOnlyList<Guid> eventIds, CancellationToken cancellationToken = default)
{
lock (_rows)
{
HashSet<Guid> ids = eventIds.ToHashSet();
_rows.RemoveAll(r => ids.Contains(r.EventId));
}
return Task.CompletedTask;
}
public Task UpdateAttemptAsync(
IReadOnlyList<Guid> eventIds,
int attemptCount,
DateTimeOffset nextAttemptUtc,
string? lastErrorCode,
CancellationToken cancellationToken = default)
{
lock (_rows)
{
HashSet<Guid> ids = eventIds.ToHashSet();
foreach (DiagnosticOutboxRow row in _rows.Where(r => ids.Contains(r.EventId)).ToList())
{
int index = _rows.IndexOf(row);
_rows[index] = new DiagnosticOutboxRow
{
EventId = row.EventId,
Priority = row.Priority,
CreatedAtUtc = row.CreatedAtUtc,
ExpiresAtUtc = row.ExpiresAtUtc,
PayloadVersion = row.PayloadVersion,
PayloadJson = row.PayloadJson,
AttemptCount = attemptCount,
NextAttemptUtc = nextAttemptUtc,
LastAttemptUtc = DateTimeOffset.UtcNow,
LastErrorCode = lastErrorCode
};
}
}
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken = default)
{
if (ThrowOnCount)
{
throw new InvalidOperationException("count down");
}
lock (_rows)
{
return Task.FromResult(_rows.Count);
}
}
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
{
lock (_rows)
{
return Task.FromResult(_rows.RemoveAll(r => r.ExpiresAtUtc <= now));
}
}
public Task<int> DeleteOldestAsync(TelemetryPriority priority, int count, CancellationToken cancellationToken = default)
{
lock (_rows)
{
List<Guid> toDelete = _rows
.Where(r => r.Priority == priority)
.OrderBy(r => r.CreatedAtUtc)
.Take(count)
.Select(r => r.EventId)
.ToList();
_rows.RemoveAll(r => toDelete.Contains(r.EventId));
return Task.FromResult(toDelete.Count);
}
}
public Task<long> GetDatabaseSizeBytesAsync(CancellationToken cancellationToken = default)
{
lock (_rows)
{
return Task.FromResult((long)_rows.Count * 100);
}
}
public IReadOnlyList<DiagnosticOutboxRow> Snapshot()
{
lock (_rows)
{
return _rows.ToList();
}
}
}