Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user