Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Omika.Core.Abstractions;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Clients;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Runtime;
|
||||
|
||||
using Omika.Core.Domain;
|
||||
|
||||
namespace Omika.Tests.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Tests HTTP des endpoints Client. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
|
||||
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
|
||||
/// et un fake <see cref="IClientService"/> pour contrôler les réponses.
|
||||
///
|
||||
/// Les tests modifient le contexte statique <see cref="ApplicationRuntimeContext"/>,
|
||||
/// donc ils sont sérialisés via la collection <c>"ApplicationRuntimeContext"</c>.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class ClientEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly FakeClientService _fakeService = new();
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ClientEndpointsTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Configuration minimale avec valeurs HFSQL factices
|
||||
// requises par ValidateOnStart().
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Hfsql:Server"] = "test-server",
|
||||
["Hfsql:DatabaseName"] = "test-db"
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
// Remplacer le runtime initializer par un fake qui
|
||||
// configure Batik comme version détectée (pas de HFSQL).
|
||||
services.RemoveAll<IDatabaseRuntimeInitializer>();
|
||||
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
|
||||
|
||||
// Remplacer IHfsqlConnection par un fake (pas d'ODBC).
|
||||
services.RemoveAll<IHfsqlConnection>();
|
||||
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
|
||||
|
||||
// Remplacer IClientService par un fake contrôlable.
|
||||
services.RemoveAll<IClientService>();
|
||||
services.AddScoped<IClientService>(_ => _fakeService);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /clients/{id}
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientById_NumericId_TransmittedAsString()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await client.GetAsync("/clients/12345");
|
||||
|
||||
Assert.Equal("12345", _fakeService.LastDetailsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientById_GuidId_TransmittedAsString()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var guid = Guid.NewGuid().ToString("D");
|
||||
|
||||
await client.GetAsync($"/clients/{guid}");
|
||||
|
||||
Assert.Equal(guid, _fakeService.LastDetailsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientById_Returns200WithDto()
|
||||
{
|
||||
_fakeService.DetailsResult = new ClientDetailsDto
|
||||
{
|
||||
Id = "test-id",
|
||||
Code = "C001",
|
||||
Nom = "Dupont"
|
||||
};
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/clients/42");
|
||||
var body = await response.Content.ReadFromJsonAsync<ClientDetailsDto>();
|
||||
|
||||
Assert.Equal(200, (int)response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("test-id", body.Id);
|
||||
Assert.Equal("C001", body.Code);
|
||||
Assert.Equal("Dupont", body.Nom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientById_NullResult_Returns404()
|
||||
{
|
||||
_fakeService.DetailsResult = null;
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/clients/999");
|
||||
|
||||
Assert.Equal(404, (int)response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientById_ServiceException_NotTransformedTo404()
|
||||
{
|
||||
_fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error");
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/clients/42");
|
||||
|
||||
// L'exception n'est pas catchée dans le endpoint → 500.
|
||||
Assert.Equal(500, (int)response.StatusCode);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HEALTH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetHealth_StillResponds()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(200, (int)response.StatusCode);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Endpoints_DependsOnlyOnIClientService()
|
||||
{
|
||||
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
|
||||
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
|
||||
// Les handlers doivent accepter IClientService, pas les repositories.
|
||||
Assert.DoesNotContain(parameters, p =>
|
||||
p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.ParameterType.Name != "IClientService");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Endpoints_HasNoOmikaDataReferences()
|
||||
{
|
||||
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
|
||||
var refs = GetReferencedNamespaces(type);
|
||||
|
||||
Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Endpoints_HasNoApplicationRuntimeContext()
|
||||
{
|
||||
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
|
||||
var refs = GetReferencedNamespaces(type);
|
||||
|
||||
Assert.DoesNotContain(refs, ns =>
|
||||
ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Endpoints_HasNoHfsqlRowReferences()
|
||||
{
|
||||
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
|
||||
var refs = GetReferencedNamespaces(type);
|
||||
|
||||
Assert.DoesNotContain(refs, ns =>
|
||||
ns.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static IEnumerable<string> GetReferencedNamespaces(Type type)
|
||||
{
|
||||
var ns = new HashSet<string>();
|
||||
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (method.DeclaringType?.Namespace is not null)
|
||||
ns.Add(method.DeclaringType.Namespace);
|
||||
|
||||
if (method.ReturnType.Namespace is not null)
|
||||
ns.Add(method.ReturnType.Namespace);
|
||||
|
||||
foreach (var p in method.GetParameters())
|
||||
{
|
||||
if (p.ParameterType.Namespace is not null)
|
||||
ns.Add(p.ParameterType.Namespace);
|
||||
}
|
||||
}
|
||||
|
||||
return ns;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FAKES
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Fake runtime initializer : configure Batik comme version détectée,
|
||||
/// sans contacter de base HFSQL.
|
||||
/// </summary>
|
||||
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
|
||||
{
|
||||
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Configurer le contexte runtime avec Batik comme version détectée.
|
||||
// Cela permet à AdaptiveClientRepository de fonctionner sans base réelle.
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
return Task.FromResult(Result.Success());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake IClientService pour les tests HTTP : enregistre les appels
|
||||
/// et retourne des données contrôlées.
|
||||
/// </summary>
|
||||
private sealed class FakeClientService : IClientService
|
||||
{
|
||||
public int SearchCallCount { get; private set; }
|
||||
public int DetailsCallCount { get; private set; }
|
||||
public int UpdateCallCount { get; private set; }
|
||||
public int ArchiveCallCount { get; private set; }
|
||||
public SearchClientsRequest? LastRequest { get; private set; }
|
||||
public string? LastDetailsId { get; private set; }
|
||||
public string? LastUpdateId { get; private set; }
|
||||
public UpdateClientRequest? LastUpdateRequest { get; private set; }
|
||||
public string? LastArchiveId { get; private set; }
|
||||
public PagedResult<ClientSummaryDto>? SearchResult { get; set; }
|
||||
public ClientDetailsDto? DetailsResult { get; set; }
|
||||
public ClientDetailsDto? UpdateResult { get; set; }
|
||||
public bool ArchiveResult { get; set; }
|
||||
public Exception? ThrowOnSearch { get; set; }
|
||||
public Exception? ThrowOnDetails { get; set; }
|
||||
public Exception? ThrowOnUpdate { get; set; }
|
||||
public Exception? ThrowOnArchive { get; set; }
|
||||
|
||||
public Task<PagedResult<ClientSummaryDto>> SearchAsync(
|
||||
SearchClientsRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCallCount++;
|
||||
LastRequest = request;
|
||||
|
||||
if (ThrowOnSearch is not null) throw ThrowOnSearch;
|
||||
|
||||
return Task.FromResult(SearchResult
|
||||
?? new PagedResult<ClientSummaryDto>([], 1, 20, 0, 0));
|
||||
}
|
||||
|
||||
public Task<PagedResult<ClientDetailsDto>> SearchDetailsAsync(
|
||||
SearchClientsRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Fake minimal : retourne un résultat vide paginé. Le snapshot mobile
|
||||
// n'est pas exercé par ces tests HTTP (ils ciblent les endpoints CRUD).
|
||||
return Task.FromResult(new PagedResult<ClientDetailsDto>([], 1, 20, 0, 0));
|
||||
}
|
||||
|
||||
public Task<ClientDetailsDto?> GetByIdAsync(
|
||||
string id, CancellationToken cancellationToken = default)
|
||||
|
||||
{
|
||||
DetailsCallCount++;
|
||||
LastDetailsId = id;
|
||||
|
||||
if (ThrowOnDetails is not null) throw ThrowOnDetails;
|
||||
|
||||
return Task.FromResult(DetailsResult);
|
||||
}
|
||||
|
||||
public Task<ClientDetailsDto> CreateAsync(
|
||||
CreateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ClientDetailsDto?> UpdateAsync(
|
||||
string id, UpdateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
{
|
||||
UpdateCallCount++;
|
||||
LastUpdateId = id;
|
||||
LastUpdateRequest = request;
|
||||
|
||||
if (ThrowOnUpdate is not null) throw ThrowOnUpdate;
|
||||
|
||||
return Task.FromResult(UpdateResult);
|
||||
}
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArchiveCallCount++;
|
||||
LastArchiveId = id;
|
||||
|
||||
if (ThrowOnArchive is not null) throw ThrowOnArchive;
|
||||
|
||||
return Task.FromResult(ArchiveResult);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake IHfsqlConnection minimal pour les tests HTTP.
|
||||
/// </summary>
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
|
||||
public void Dispose() { }
|
||||
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task CloseAsync() => Task.CompletedTask;
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user