Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Diagnostics.Models;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>Fabriques d'événements et d'enregistrements JSONL pour les tests.</summary>
|
||||
public static class DiagnosticTestData
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static RemoteDiagnosticRecord Record(
|
||||
Guid eventId,
|
||||
string eventCode = "TEST",
|
||||
DateTimeOffset? timestamp = null,
|
||||
Guid? traceId = null,
|
||||
Guid? sessionId = null,
|
||||
Guid? operationId = null,
|
||||
string? component = null,
|
||||
string level = "Info",
|
||||
string priority = "P2",
|
||||
string? errorCode = null,
|
||||
long? durationMs = null,
|
||||
Dictionary<string, object?>? metadata = null,
|
||||
string? actorKey = null,
|
||||
int? actorKeyVersion = null,
|
||||
string? clientCode = null,
|
||||
string? serverInstance = null,
|
||||
string? environment = null)
|
||||
{
|
||||
DateTimeOffset ts = timestamp ?? DateTimeOffset.UtcNow;
|
||||
return new RemoteDiagnosticRecord
|
||||
{
|
||||
ReceivedAtUtc = ts,
|
||||
BatchId = Guid.NewGuid(),
|
||||
IngestionTraceId = Guid.NewGuid().ToString("D"),
|
||||
ActorKey = actorKey,
|
||||
ActorKeyVersion = actorKeyVersion,
|
||||
ClientCode = clientCode,
|
||||
ServerInstance = serverInstance,
|
||||
Environment = environment,
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = eventCode,
|
||||
TimestampUtc = ts,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Component = component,
|
||||
TraceId = traceId,
|
||||
SessionId = sessionId,
|
||||
OperationId = operationId,
|
||||
ErrorCode = errorCode,
|
||||
DurationMs = durationMs,
|
||||
Metadata = metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static DiagnosticEventRecord Event(
|
||||
Guid eventId,
|
||||
string eventCode = "TEST",
|
||||
DateTimeOffset? timestamp = null,
|
||||
Guid? traceId = null,
|
||||
Guid? operationId = null,
|
||||
string? component = null,
|
||||
string level = "Info",
|
||||
string? errorCode = null,
|
||||
long? durationMs = null,
|
||||
string? actorKey = null,
|
||||
int? actorKeyVersion = null)
|
||||
{
|
||||
return new DiagnosticEventRecord
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = eventCode,
|
||||
TimestampUtc = timestamp ?? DateTimeOffset.UtcNow,
|
||||
TraceId = traceId,
|
||||
OperationId = operationId,
|
||||
Component = component,
|
||||
Level = level,
|
||||
Priority = "P2",
|
||||
ErrorCode = errorCode,
|
||||
DurationMs = durationMs,
|
||||
ActorKey = actorKey,
|
||||
ActorKeyVersion = actorKeyVersion,
|
||||
Source = DiagnosticSources.Mobile,
|
||||
IngestedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public static string SerializeLine(RemoteDiagnosticRecord record) =>
|
||||
JsonSerializer.Serialize(record, JsonOptions);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Diagnostics.Import;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Source JSONL en mémoire : modèle un flux de <see cref="RemoteDiagnosticRecord"/>
|
||||
/// avec un checkpoint (LineIndex = index du dernier enregistrement lu). Permet de
|
||||
/// tester l'importeur et son avancement de checkpoint sans PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class FakeDiagnosticJsonlSource : IDiagnosticJsonlSource
|
||||
{
|
||||
private readonly List<RemoteDiagnosticRecord> _records;
|
||||
private DiagnosticImportCheckpoint? _checkpoint;
|
||||
|
||||
public FakeDiagnosticJsonlSource(IReadOnlyList<RemoteDiagnosticRecord> records)
|
||||
{
|
||||
_records = records.ToList();
|
||||
}
|
||||
|
||||
public int CommitCount { get; private set; }
|
||||
|
||||
public DiagnosticImportCheckpoint? LastCommitted { get; private set; }
|
||||
|
||||
public DiagnosticImportCheckpoint? LoadCheckpoint() => _checkpoint;
|
||||
|
||||
public DiagnosticJsonlReadResult ReadNext(DiagnosticImportCheckpoint? after, int maxCount)
|
||||
{
|
||||
int start = after is null ? 0 : after.LineIndex + 1;
|
||||
if (start >= _records.Count)
|
||||
{
|
||||
return new DiagnosticJsonlReadResult { ReachedEnd = true };
|
||||
}
|
||||
|
||||
int count = Math.Min(maxCount, _records.Count - start);
|
||||
List<RemoteDiagnosticRecord> records = _records.GetRange(start, count);
|
||||
|
||||
var next = new DiagnosticImportCheckpoint
|
||||
{
|
||||
FileName = "fake.jsonl",
|
||||
LineIndex = start + count - 1
|
||||
};
|
||||
|
||||
return new DiagnosticJsonlReadResult
|
||||
{
|
||||
Records = records,
|
||||
NextPosition = next,
|
||||
ReachedEnd = start + count >= _records.Count
|
||||
};
|
||||
}
|
||||
|
||||
public void Commit(DiagnosticImportCheckpoint position)
|
||||
{
|
||||
_checkpoint = position;
|
||||
LastCommitted = position;
|
||||
CommitCount++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Omika.Diagnostics.Referential;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Fake en mémoire de <see cref="IDiagnosticReferenceRepository"/> pour les tests sans
|
||||
/// PostgreSQL. Reproduit la sémantique du repository Npgsql : normalisation TRIM+UPPER,
|
||||
/// unicité (type, code), code immuable, archivage (enabled=false) sans DELETE. Les codes
|
||||
/// « observés » (pour <see cref="GetUnreferencedAsync"/>) sont injectés via
|
||||
/// <see cref="SeedObserved"/> (miroir du DISTINCT diagnostic_event, sans base réelle).
|
||||
/// </summary>
|
||||
public sealed class FakeDiagnosticReferenceRepository : IDiagnosticReferenceRepository
|
||||
{
|
||||
private readonly List<DiagnosticReferenceRecord> _references = [];
|
||||
private readonly List<(string Type, string Code, long Count, DateTimeOffset LastSeen)> _observed = [];
|
||||
private long _nextId = 1;
|
||||
|
||||
public IReadOnlyList<DiagnosticReferenceRecord> Seed(params DiagnosticReferenceRecord[] records)
|
||||
{
|
||||
foreach (DiagnosticReferenceRecord r in records)
|
||||
{
|
||||
if (r.Id == 0)
|
||||
{
|
||||
r.Id = _nextId++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_nextId = Math.Max(_nextId, r.Id + 1);
|
||||
}
|
||||
|
||||
_references.Add(r);
|
||||
}
|
||||
|
||||
return _references;
|
||||
}
|
||||
|
||||
public void SeedObserved(string type, string code, long count, DateTimeOffset lastSeen) =>
|
||||
_observed.Add((type, code, count, lastSeen));
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticReferenceRecord>> ListAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<DiagnosticReferenceRecord> ordered = _references
|
||||
.OrderBy(r => r.ReferenceType, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.SortOrder)
|
||||
.ThenBy(r => r.Code, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
public Task<DiagnosticReferenceRecord?> GetByTypeAndCodeAsync(
|
||||
string referenceType,
|
||||
string code,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? normalized = DiagnosticReferenceNormalization.NormalizeCode(code);
|
||||
DiagnosticReferenceRecord? found = _references.FirstOrDefault(r =>
|
||||
r.ReferenceType == referenceType && r.Code == normalized);
|
||||
return Task.FromResult(found);
|
||||
}
|
||||
|
||||
public Task<DiagnosticReferenceRecord?> GetByIdAsync(
|
||||
long id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_references.FirstOrDefault(r => r.Id == id));
|
||||
}
|
||||
|
||||
public Task<DiagnosticReferenceRecord?> CreateAsync(
|
||||
string referenceType,
|
||||
string code,
|
||||
string label,
|
||||
string? description,
|
||||
bool enabled,
|
||||
int sortOrder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? normalizedCode = DiagnosticReferenceNormalization.NormalizeCode(code);
|
||||
string? normalizedLabel = DiagnosticReferenceNormalization.NormalizeLabel(label);
|
||||
if (!DiagnosticReferenceTypes.IsSupported(referenceType) || normalizedCode is null || normalizedLabel is null)
|
||||
{
|
||||
return Task.FromResult<DiagnosticReferenceRecord?>(null);
|
||||
}
|
||||
|
||||
if (_references.Any(r => r.ReferenceType == referenceType && r.Code == normalizedCode))
|
||||
{
|
||||
return Task.FromResult<DiagnosticReferenceRecord?>(null); // doublon.
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var record = new DiagnosticReferenceRecord
|
||||
{
|
||||
Id = _nextId++,
|
||||
ReferenceType = referenceType,
|
||||
Code = normalizedCode,
|
||||
Label = normalizedLabel,
|
||||
Description = DiagnosticReferenceNormalization.NormalizeLabel(description),
|
||||
Enabled = enabled,
|
||||
SortOrder = sortOrder,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
_references.Add(record);
|
||||
return Task.FromResult<DiagnosticReferenceRecord?>(record);
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(
|
||||
long id,
|
||||
string label,
|
||||
string? description,
|
||||
bool enabled,
|
||||
int sortOrder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? normalizedLabel = DiagnosticReferenceNormalization.NormalizeLabel(label);
|
||||
DiagnosticReferenceRecord? record = _references.FirstOrDefault(r => r.Id == id);
|
||||
if (record is null || normalizedLabel is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
record.Label = normalizedLabel;
|
||||
record.Description = DiagnosticReferenceNormalization.NormalizeLabel(description);
|
||||
record.Enabled = enabled;
|
||||
record.SortOrder = sortOrder;
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, string>> GetLabelsAsync(
|
||||
string referenceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var labels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (DiagnosticReferenceRecord r in _references.Where(r => r.ReferenceType == referenceType))
|
||||
{
|
||||
labels[r.Code] = r.Label;
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, string>>(labels);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticUnreferencedCode>> GetUnreferencedAsync(
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _observed
|
||||
.Where(o => !_references.Any(r => r.ReferenceType == o.Type && r.Code == o.Code))
|
||||
.Select(o => new DiagnosticUnreferencedCode(o.Type, o.Code, o.Count, o.LastSeen))
|
||||
.OrderByDescending(x => x.ObservedCount)
|
||||
.ThenBy(x => x.ReferenceType, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Code, StringComparer.Ordinal)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DiagnosticUnreferencedCode>>(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Omika.Diagnostics.Tasks;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Fake en mémoire de <see cref="IDiagnosticTaskRepository"/> pour les tests sans
|
||||
/// PostgreSQL. Reproduit la sémantique du repository Npgsql : unicité par signature
|
||||
/// (group_url_identifier), statut normalisé, date de résolution dérivée du statut.
|
||||
/// </summary>
|
||||
public sealed class FakeDiagnosticTaskRepository : IDiagnosticTaskRepository
|
||||
{
|
||||
private readonly List<DiagnosticTaskRecord> _tasks = [];
|
||||
private long _nextId = 1;
|
||||
|
||||
public IReadOnlyList<DiagnosticTaskRecord> Seed(params DiagnosticTaskRecord[] tasks)
|
||||
{
|
||||
foreach (DiagnosticTaskRecord t in tasks)
|
||||
{
|
||||
if (t.Id == 0)
|
||||
{
|
||||
t.Id = _nextId++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_nextId = Math.Max(_nextId, t.Id + 1);
|
||||
}
|
||||
|
||||
_tasks.Add(t);
|
||||
}
|
||||
|
||||
return _tasks;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticTaskRecord>> ListAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<DiagnosticTaskRecord> ordered = _tasks
|
||||
.OrderByDescending(t => t.UpdatedAtUtc)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
public Task<DiagnosticTaskRecord?> GetByIdAsync(
|
||||
long id,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id));
|
||||
|
||||
public Task<DiagnosticTaskRecord?> GetByGroupAsync(
|
||||
string groupUrlIdentifier,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_tasks.FirstOrDefault(t => t.GroupUrlIdentifier == groupUrlIdentifier));
|
||||
|
||||
public Task<DiagnosticTaskRecord?> CreateAsync(
|
||||
string groupUrlIdentifier,
|
||||
string title,
|
||||
string? description,
|
||||
string? cause,
|
||||
string status,
|
||||
string createdByUsername,
|
||||
string? assigneeUsername,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? group = Normalize(groupUrlIdentifier);
|
||||
string? normalizedTitle = Normalize(title);
|
||||
if (group is null || normalizedTitle is null)
|
||||
{
|
||||
return Task.FromResult<DiagnosticTaskRecord?>(null);
|
||||
}
|
||||
|
||||
if (_tasks.Any(t => t.GroupUrlIdentifier == group))
|
||||
{
|
||||
return Task.FromResult<DiagnosticTaskRecord?>(null); // doublon.
|
||||
}
|
||||
|
||||
string normalizedStatus = DiagnosticTaskStatuses.Normalize(status);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var record = new DiagnosticTaskRecord
|
||||
{
|
||||
Id = _nextId++,
|
||||
GroupUrlIdentifier = group,
|
||||
Title = normalizedTitle,
|
||||
Description = Normalize(description),
|
||||
Cause = Normalize(cause),
|
||||
Status = normalizedStatus,
|
||||
CreatedByUsername = createdByUsername,
|
||||
AssigneeUsername = Normalize(assigneeUsername),
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
ResolvedAtUtc = DiagnosticTaskStatuses.IsTerminal(normalizedStatus) ? now : null
|
||||
};
|
||||
_tasks.Add(record);
|
||||
return Task.FromResult<DiagnosticTaskRecord?>(record);
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(
|
||||
long id,
|
||||
string title,
|
||||
string? description,
|
||||
string? cause,
|
||||
string status,
|
||||
string? assigneeUsername,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiagnosticTaskRecord? task = _tasks.FirstOrDefault(t => t.Id == id);
|
||||
string? normalizedTitle = Normalize(title);
|
||||
if (task is null || normalizedTitle is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
string normalizedStatus = DiagnosticTaskStatuses.Normalize(status);
|
||||
task.Title = normalizedTitle;
|
||||
task.Description = Normalize(description);
|
||||
task.Cause = Normalize(cause);
|
||||
task.Status = normalizedStatus;
|
||||
task.AssigneeUsername = Normalize(assigneeUsername);
|
||||
task.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
task.ResolvedAtUtc = DiagnosticTaskStatuses.IsTerminal(normalizedStatus)
|
||||
? task.ResolvedAtUtc ?? DateTimeOffset.UtcNow
|
||||
: null;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
long id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int removed = _tasks.RemoveAll(t => t.Id == id);
|
||||
return Task.FromResult(removed > 0);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Omika.Diagnostics.Users;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Fake en mémoire de <see cref="IDiagnosticUserRepository"/> pour les tests sans PostgreSQL.
|
||||
/// Reproduit la sémantique du repository Npgsql : unicité insensible à la casse, tri par
|
||||
/// username, hash stocké (jamais de mot de passe en clair).
|
||||
/// </summary>
|
||||
public sealed class FakeDiagnosticUserRepository : IDiagnosticUserRepository
|
||||
{
|
||||
private readonly List<DiagnosticUserRecord> _users = [];
|
||||
private long _nextId = 1;
|
||||
|
||||
public IReadOnlyList<DiagnosticUserRecord> Seed(params DiagnosticUserRecord[] users)
|
||||
{
|
||||
foreach (DiagnosticUserRecord u in users)
|
||||
{
|
||||
if (u.Id == 0)
|
||||
{
|
||||
u.Id = _nextId++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_nextId = Math.Max(_nextId, u.Id + 1);
|
||||
}
|
||||
|
||||
_users.Add(u);
|
||||
}
|
||||
|
||||
return _users;
|
||||
}
|
||||
|
||||
public Task<DiagnosticUserRecord?> GetByUsernameAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? normalized = username?.Trim();
|
||||
DiagnosticUserRecord? found = _users.FirstOrDefault(u =>
|
||||
string.Equals(u.Username, normalized, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult(found);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticUserRecord>> ListAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<DiagnosticUserRecord> ordered = _users
|
||||
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
public Task<DiagnosticUserRecord?> CreateAsync(
|
||||
string username,
|
||||
string passwordHash,
|
||||
string role,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? normalized = username?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return Task.FromResult<DiagnosticUserRecord?>(null);
|
||||
}
|
||||
|
||||
if (_users.Any(u => string.Equals(u.Username, normalized, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Task.FromResult<DiagnosticUserRecord?>(null); // doublon.
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var record = new DiagnosticUserRecord
|
||||
{
|
||||
Id = _nextId++,
|
||||
Username = normalized,
|
||||
PasswordHash = passwordHash,
|
||||
Role = role,
|
||||
Enabled = true,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
PasswordChangedAtUtc = now,
|
||||
LastLoginAtUtc = null
|
||||
};
|
||||
_users.Add(record);
|
||||
return Task.FromResult<DiagnosticUserRecord?>(record);
|
||||
}
|
||||
|
||||
public Task<bool> SetEnabledAsync(
|
||||
long id,
|
||||
bool enabled,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
|
||||
if (user is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
user.Enabled = enabled;
|
||||
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> SetRoleAsync(
|
||||
long id,
|
||||
string role,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
|
||||
if (user is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
user.Role = role;
|
||||
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> UpdatePasswordHashAsync(
|
||||
long id,
|
||||
string passwordHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
|
||||
if (user is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
user.PasswordHash = passwordHash;
|
||||
user.PasswordChangedAtUtc = DateTimeOffset.UtcNow;
|
||||
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task UpdateLastLoginAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiagnosticUserRecord? user = _users.FirstOrDefault(u =>
|
||||
string.Equals(u.Username, username?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (user is not null)
|
||||
{
|
||||
user.LastLoginAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Omika.Diagnostics.Models;
|
||||
using Omika.Diagnostics.Repositories;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Repository télémétrie in-memory (sans PostgreSQL) : reproduit le contrat
|
||||
/// <c>ITelemetryMetricRepository</c> (upsert + purge bornée par bucket horaire).
|
||||
/// </summary>
|
||||
public sealed class FakeTelemetryMetricRepository : ITelemetryMetricRepository
|
||||
{
|
||||
private readonly List<TelemetryMetricHourly> _all = new();
|
||||
private readonly object _sync = new();
|
||||
private long _nextId = 1;
|
||||
|
||||
public IReadOnlyList<TelemetryMetricHourly> All
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _all.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpsertHourlyAsync(
|
||||
IReadOnlyList<TelemetryMetricHourly> metrics,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (TelemetryMetricHourly m in metrics)
|
||||
{
|
||||
m.Id = _nextId++;
|
||||
_all.Add(m);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> DeleteOlderThanAsync(
|
||||
DateTimeOffset cutoff,
|
||||
int batchSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
List<TelemetryMetricHourly> toDelete = _all
|
||||
.Where(m => m.BucketUtc < cutoff)
|
||||
.OrderBy(m => m.BucketUtc)
|
||||
.ThenBy(m => m.Id)
|
||||
.Take(batchSize)
|
||||
.ToList();
|
||||
|
||||
foreach (TelemetryMetricHourly m in toDelete)
|
||||
{
|
||||
_all.Remove(m);
|
||||
}
|
||||
|
||||
return Task.FromResult(toDelete.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simule PostgreSQL DOWN pour la télémétrie : toutes les opérations lèvent.
|
||||
/// La boucle de rétention doit survivre (fail-open).
|
||||
/// </summary>
|
||||
public sealed class ThrowingTelemetryMetricRepository : ITelemetryMetricRepository
|
||||
{
|
||||
public Task UpsertHourlyAsync(
|
||||
IReadOnlyList<TelemetryMetricHourly> metrics,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<int> DeleteOlderThanAsync(
|
||||
DateTimeOffset cutoff,
|
||||
int batchSize,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Omika.Diagnostics.Models;
|
||||
using Omika.Diagnostics.Queries;
|
||||
using Omika.Diagnostics.Repositories;
|
||||
|
||||
namespace Omika.Diagnostics.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Simule PostgreSQL DOWN (§52) : toutes les opérations lèvent, la sonde retourne
|
||||
/// <c>false</c>. L'importeur doit survivre (résultat retryable), laisser le JSONL
|
||||
/// intact et ne pas avancer le checkpoint.
|
||||
/// </summary>
|
||||
public sealed class ThrowingDiagnosticRepository : IDiagnosticEventRepository
|
||||
{
|
||||
public Task<int> InsertBatchAsync(
|
||||
IReadOnlyList<DiagnosticEventRecord> events,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticQueryResult<DiagnosticEventRecord>> QueryAsync(
|
||||
DiagnosticEventQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticEventRecord>> GetByTraceIdAsync(
|
||||
Guid traceId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticEventRecord>> GetByOperationIdAsync(
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> DeleteOlderThanAsync(
|
||||
DateTimeOffset cutoff,
|
||||
int batchSize,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<long> DeleteErrorGroupAsync(
|
||||
DiagnosticErrorGroupIdentity identity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticGroupCount>> CountGroupedAsync(
|
||||
DiagnosticGroupDimension dimension,
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticBucketCount>> CountByTimeBucketAsync(
|
||||
DiagnosticTimeBucket bucket,
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticBucketClientCount>> CountByTimeBucketAndClientAsync(
|
||||
DiagnosticTimeBucket bucket,
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticLicenseErrorCount>> CountLicensesByErrorsAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticGroupCount>> CountErrorsByClientAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticQueryResult<DiagnosticEventRecord>> SearchAsync(
|
||||
DiagnosticSearchQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticErrorGroup>> GroupErrorsAsync(
|
||||
DiagnosticErrorGroupQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<long> CountAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<long> CountErrorGroupsAsync(
|
||||
DiagnosticErrorGroupQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticDurationStats?> GetDurationStatsAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticDurationByEventCode>> GetTopDurationByEventCodeAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticVersionSummary>> GetVersionSummariesAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticVersionTotals> GetVersionTotalsAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticSyncSummary> GetSyncSummaryAsync(
|
||||
DiagnosticAggregateQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticErrorGroup?> GetErrorGroupAsync(
|
||||
DiagnosticErrorGroupOccurrenceQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticEventRecord>> GetErrorGroupOccurrencesAsync(
|
||||
DiagnosticErrorGroupOccurrenceQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticGroupCount>> CountErrorGroupByClientAsync(
|
||||
DiagnosticErrorGroupOccurrenceQuery query,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticLicenseErrorCount>> CountErrorGroupByLicenseAsync(
|
||||
DiagnosticErrorGroupOccurrenceQuery query,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DiagnosticEventRecord?> GetByIdAsync(
|
||||
long id,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticEventRecord>> GetCorrelatedAsync(
|
||||
DiagnosticCorrelationQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
query.Validate();
|
||||
|
||||
// Sans identifiant, le contrat exige « aucune requête » (§T3/17) : le résultat
|
||||
// vide est retourné SANS toucher PostgreSQL, même si la base est down.
|
||||
return query.HasAnyIdentifier
|
||||
? throw new InvalidOperationException("PostgreSQL DOWN simulé")
|
||||
: Task.FromResult<IReadOnlyList<DiagnosticEventRecord>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetFilterOptionsAsync(
|
||||
DiagnosticFilterDimension dimension,
|
||||
DateTimeOffset fromUtc,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticRecentCorrelation>> GetRecentCorrelationsAsync(
|
||||
DiagnosticCorrelationDimension dimension,
|
||||
DateTimeOffset fromUtc,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<long> CountActiveDevicesAsync(
|
||||
DeviceFleetQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DeviceDistribution>> CountDevicesGroupedAsync(
|
||||
DeviceFleetDimension dimension,
|
||||
DeviceFleetQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<DeviceFleetQueryResult> GetDevicesAsync(
|
||||
DeviceFleetQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<DeviceDistribution>> CountDevicesByLicenseAsync(
|
||||
DeviceFleetQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<ScreenUsageRecord>> GetScreenUsageAsync(
|
||||
UsageTelemetryQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
|
||||
public Task<IReadOnlyList<FeatureUsageRecord>> GetFeatureUsageAsync(
|
||||
UsageTelemetryQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new InvalidOperationException("PostgreSQL DOWN simulé");
|
||||
}
|
||||
Reference in New Issue
Block a user