Sube carpetas API, API.Tests, Application y Domain del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:35:59 -03:00
parent bbb09554bb
commit 2eb4eeef03
572 changed files with 56405 additions and 0 deletions
@@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// A single log call captured by CapturingLogger{T}.
/// </summary>
public sealed record CapturingLogEntry(LogLevel Level, string Message, Exception? Exception);
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Minimal ILogger{T} test double that records every log entry
/// so tests can assert a failure was actually logged (spec requirement),
/// without pulling in a logging test-framework dependency.
/// </summary>
public sealed class CapturingLogger<T> : ILogger<T>
{
private readonly List<CapturingLogEntry> _entries = [];
public IReadOnlyList<CapturingLogEntry> Entries => _entries;
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
return NullScope.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (_entries)
{
_entries.Add(new CapturingLogEntry(logLevel, formatter(state, exception), exception));
}
}
}
@@ -0,0 +1,40 @@
using Application.DTOs.Abstract.Response;
using Application.DTOs.AuditLogs.Request;
using Application.Interfaces.Services;
using Domain.Entities.Models;
using Domain.Enums;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IAuditService. Records every call so tests can assert on
/// what got logged without a real repository/database.
/// </summary>
public sealed class FakeAuditService : IAuditService
{
public List<AuditAction> LoggedActions { get; } = [];
public Task LogAsync(
AuditAction action,
string? targetType = null,
string? targetId = null,
string? targetName = null,
string? detail = null,
CancellationToken ct = default)
{
LoggedActions.Add(action);
return Task.CompletedTask;
}
public Task<PaginatedResponse<AuditLog>> GetAuditLogsAsync(AuditLogFilteredRequest filter)
{
return Task.FromResult(new PaginatedResponse<AuditLog>
{
Page = filter.PageNumber,
PageSize = filter.PageSize,
TotalCount = 0,
Items = [],
});
}
}
@@ -0,0 +1,50 @@
using Application.Interfaces.Backup;
using Domain.Entities.Models;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// In-memory test double for IBackupCatalog. Lets
/// BackupOperationsServiceTests and BackupControllerTests exercise
/// the shared create/delete/retention logic without a real database.
/// </summary>
public sealed class FakeBackupCatalog : IBackupCatalog
{
private readonly List<BackupRecord> _records = [];
public int AddCallCount { get; private set; }
public int RemoveCallCount { get; private set; }
public Task<BackupRecord> AddAsync(BackupRecord record, CancellationToken ct = default)
{
AddCallCount++;
if (record.Id == Guid.Empty)
{
record.Id = Guid.NewGuid();
}
_records.Add(record);
return Task.FromResult(record);
}
public Task<BackupRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return Task.FromResult(_records.Find(r => r.Id == id));
}
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
{
IReadOnlyList<BackupRecord> ordered = _records
.OrderByDescending(r => r.DateCreated)
.ToList();
return Task.FromResult(ordered);
}
public Task RemoveAsync(Guid id, CancellationToken ct = default)
{
RemoveCallCount++;
_records.RemoveAll(r => r.Id == id);
return Task.CompletedTask;
}
}
@@ -0,0 +1,76 @@
using Application.DTOs.Backup.Response;
using Application.Interfaces.Backup;
using Domain.Entities.Models;
using Domain.Enums;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IBackupOperationsService. Used both by
/// BackupControllerTests (configuring the exact outcome an endpoint
/// call should map to an HTTP status) and by
/// DatabaseBackupHostedServiceTests (using Gate/FailFirstCalls to
/// simulate a slow/failing scheduled attempt).
/// </summary>
public sealed class FakeBackupOperationsService : IBackupOperationsService
{
private int _createCallCount;
public int CreateCallCount => _createCallCount;
/// <summary>
/// When set, CreateBackupAsync awaits this before returning —
/// simulates a still-running attempt for concurrency-related tests.
/// </summary>
public TaskCompletionSource<bool>? Gate { get; set; }
/// <summary>
/// The first N calls to CreateBackupAsync return
/// Failed instead of NextCreateResult.
/// </summary>
public int FailFirstCalls { get; set; }
public BackupOperationResult NextCreateResult { get; set; } =
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Manual", "fake.sql"), null);
public BackupOperationResult NextDeleteResult { get; set; } =
new(BackupOperationOutcome.Completed, null, null);
public BackupOperationResult NextRestoreResult { get; set; } =
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Job", "safety.sql"), null);
public Guid? LastRestoreId { get; private set; }
public IReadOnlyList<BackupRecord> NextListResult { get; set; } = [];
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
{
return Task.FromResult(NextListResult);
}
public async Task<BackupOperationResult> CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default)
{
int call = Interlocked.Increment(ref _createCallCount);
if (Gate is not null)
{
await Gate.Task;
}
return call <= FailFirstCalls
? new BackupOperationResult(BackupOperationOutcome.Failed, null, $"Simulated failure on call {call}.")
: NextCreateResult;
}
public Task<BackupOperationResult> DeleteBackupAsync(Guid id, CancellationToken ct = default)
{
return Task.FromResult(NextDeleteResult);
}
public Task<BackupOperationResult> RestoreBackupAsync(Guid id, CancellationToken ct = default)
{
LastRestoreId = id;
return Task.FromResult(NextRestoreResult);
}
}
@@ -0,0 +1,74 @@
using Application.Interfaces.Backup;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IBackupStorage. Records call counts and
/// deleted names so hosted-service tests can assert on storage/retention
/// interaction without any real I/O.
/// </summary>
public sealed class FakeBackupStorage : IBackupStorage
{
private int _storeCallCount;
private int _listCallCount;
private int _deleteCallCount;
public int StoreCallCount => _storeCallCount;
public int ListCallCount => _listCallCount;
public int DeleteCallCount => _deleteCallCount;
public IReadOnlyList<BackupFile> FilesToList { get; set; } = Array.Empty<BackupFile>();
public List<string> DeletedNames { get; } = [];
/// <summary>
/// When set, DeleteAsync throws this instead of succeeding —
/// simulates the stored file already being missing out-of-band.
/// </summary>
public Exception? DeleteException { get; set; }
public Task StoreAsync(string name, Stream content, CancellationToken ct = default)
{
Interlocked.Increment(ref _storeCallCount);
return Task.CompletedTask;
}
public Task<IReadOnlyList<BackupFile>> ListAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref _listCallCount);
return Task.FromResult(FilesToList);
}
public Task DeleteAsync(string name, CancellationToken ct = default)
{
Interlocked.Increment(ref _deleteCallCount);
lock (DeletedNames)
{
DeletedNames.Add(name);
}
if (DeleteException is not null)
{
throw DeleteException;
}
return Task.CompletedTask;
}
/// <summary>
/// When set, OpenReadAsync throws this instead of succeeding —
/// simulates a missing/unreadable stored file.
/// </summary>
public Exception? OpenReadException { get; set; }
public Stream ContentToOpen { get; set; } = new MemoryStream();
public Task<Stream> OpenReadAsync(string name, CancellationToken ct = default)
{
if (OpenReadException is not null)
{
throw OpenReadException;
}
return Task.FromResult(ContentToOpen);
}
}
@@ -0,0 +1,44 @@
using Application.Interfaces.Backup;
using System.Text;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IDatabaseBackupService. Supports simulating
/// a slow/still-running dump (via Gate) for single-flight tests,
/// and simulating the first N calls failing (via FailFirstCalls)
/// for failure-isolation tests.
/// </summary>
public sealed class FakeDatabaseBackupService : IDatabaseBackupService
{
private int _callCount;
public int CallCount => _callCount;
/// <summary>
/// When set, CreateDumpAsync awaits this before returning/throwing.
/// </summary>
public TaskCompletionSource<bool>? Gate { get; set; }
/// <summary>
/// The first N calls throw BackupExecutionException; subsequent calls succeed.
/// </summary>
public int FailFirstCalls { get; set; }
public string DumpContent { get; set; } = "-- fake dump --";
public async Task<Stream> CreateDumpAsync(CancellationToken ct = default)
{
int call = Interlocked.Increment(ref _callCount);
if (Gate is not null)
{
await Gate.Task;
}
return call <= FailFirstCalls
? throw new BackupExecutionException($"Simulated backup failure on call {call}.")
: (Stream) new MemoryStream(Encoding.UTF8.GetBytes(DumpContent));
}
}
@@ -0,0 +1,37 @@
using Application.Interfaces.Backup;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IDatabaseRestoreService. No real psql binary
/// involved — records the dump file path it was invoked with (so tests can
/// assert the temp file cleanup happens after the call) and can be
/// configured to throw to simulate a failed restore (spec
/// database-restore#Restore-Failure-Is-Logged-and-Isolated).
/// </summary>
public sealed class FakeDatabaseRestoreService : IDatabaseRestoreService
{
private int _callCount;
public int CallCount => _callCount;
public string? CapturedDumpFilePath { get; private set; }
/// <summary>
/// When set, RestoreAsync throws this instead of succeeding.
/// </summary>
public Exception? ExceptionToThrow { get; set; }
public Task RestoreAsync(string dumpFilePath, CancellationToken ct = default)
{
Interlocked.Increment(ref _callCount);
CapturedDumpFilePath = dumpFilePath;
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,33 @@
using Application.Interfaces.Backup;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for IProcessRunner. Records exactly what it was
/// invoked with (file name, argument vector, environment variables) so tests
/// can assert on those without a real subprocess, and returns a
/// pre-configured ProcessResult.
/// </summary>
public sealed class FakeProcessRunner : IProcessRunner
{
public string? CapturedFileName { get; private set; }
public IReadOnlyList<string>? CapturedArgs { get; private set; }
public IReadOnlyDictionary<string, string>? CapturedEnvironmentVariables { get; private set; }
public int CallCount { get; private set; }
public ProcessResult ResultToReturn { get; set; } = new(0, string.Empty, string.Empty);
public Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> args,
IReadOnlyDictionary<string, string>? environmentVariables = null,
CancellationToken ct = default)
{
CallCount++;
CapturedFileName = fileName;
CapturedArgs = args;
CapturedEnvironmentVariables = environmentVariables;
return Task.FromResult(ResultToReturn);
}
}
@@ -0,0 +1,89 @@
using Application.Utils.Helper.SupabaseHelper;
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Test double for ISupabaseRawStorage. Records the exact
/// object paths (and, per HU medical-records-storage-eligibility, the target
/// bucket) passed to each raw call and can be configured to throw (simulating
/// a network/auth error from the real Supabase client), so
/// SupabaseBackupStorage and SupabaseMedicalRecordStorage can be unit tested
/// without a real SupabaseHelper (whose constructor performs real network
/// initialization) or any network call — per this change's spec Non-Goal on
/// actual Supabase upload verification.
/// </summary>
public sealed class FakeSupabaseRawStorage : ISupabaseRawStorage
{
public List<string> UploadedPaths { get; } = [];
/// <summary>
/// The <c>bucket</c> argument passed to each <see cref="UploadRawAsync"/>
/// call, in order — <see langword="null"/> when the caller did not pass a
/// bucket (i.e. relied on the configured default).
/// </summary>
public List<string?> UploadedBuckets { get; } = [];
public List<string> RemovedPaths { get; } = [];
public string? LastListedPrefix { get; private set; }
public string? LastDownloadedPath { get; private set; }
/// <summary>The <c>bucket</c> argument passed to the last <see cref="DownloadRawAsync"/> call.</summary>
public string? DownloadedBucket { get; private set; }
public IReadOnlyList<SupabaseStorageEntry> EntriesToList { get; set; } = Array.Empty<SupabaseStorageEntry>();
public byte[] BytesToDownload { get; set; } = Array.Empty<byte>();
/// <summary>
/// When set, every raw call throws this exception instead of succeeding —
/// simulates a network error, auth error, or any other Supabase client
/// failure.
/// </summary>
public Exception? ExceptionToThrow { get; set; }
public Task UploadRawAsync(string objectPath, Stream content, string? bucket = null)
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
UploadedPaths.Add(objectPath);
UploadedBuckets.Add(bucket);
return Task.CompletedTask;
}
public Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null)
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
LastListedPrefix = prefix;
return Task.FromResult(EntriesToList);
}
public Task RemoveRawAsync(string objectPath, string? bucket = null)
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
RemovedPaths.Add(objectPath);
return Task.CompletedTask;
}
public Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null)
{
if (ExceptionToThrow is not null)
{
throw ExceptionToThrow;
}
LastDownloadedPath = objectPath;
DownloadedBucket = bucket;
return Task.FromResult(BytesToDownload);
}
}
@@ -0,0 +1,14 @@
namespace API.Tests.Backup.Fakes;
/// <summary>
/// No-op IDisposable returned from ILogger.BeginScope, shared by every
/// CapturingLogger{T} instance regardless of T.
/// </summary>
public sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose()
{
}
}
@@ -0,0 +1,24 @@
namespace API.Tests.Backup.Fakes;
/// <summary>
/// Polls a condition instead of sleeping for a fixed real-time duration, so
/// timing-sensitive hosted-service tests stay fast on quick machines and
/// resilient (non-flaky) on slow/loaded CI runners.
/// </summary>
internal static class TestTiming
{
public static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return true;
}
await Task.Delay(10);
}
return condition();
}
}