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; /// /// Tests HTTP des endpoints Client. Utilise /// avec un fake (pas de HFSQL) /// et un fake pour contrôler les réponses. /// /// Les tests modifient le contexte statique , /// donc ils sont sérialisés via la collection "ApplicationRuntimeContext". /// [Collection("ApplicationRuntimeContext")] public class ClientEndpointsTests : IDisposable { private readonly FakeClientService _fakeService = new(); private readonly WebApplicationFactory _factory; public ClientEndpointsTests() { ApplicationRuntimeContext.Reset(); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Configuration minimale avec valeurs HFSQL factices // requises par ValidateOnStart(). var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Hfsql:Server"] = "test-server", ["Hfsql:DatabaseName"] = "test-db" }) .Build(); services.AddSingleton(config); // Remplacer le runtime initializer par un fake qui // configure Batik comme version détectée (pas de HFSQL). services.RemoveAll(); services.AddScoped(); // Remplacer IHfsqlConnection par un fake (pas d'ODBC). services.RemoveAll(); services.AddScoped(); // Remplacer IClientService par un fake contrôlable. services.RemoveAll(); services.AddScoped(_ => _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(); 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 GetReferencedNamespaces(Type type) { var ns = new HashSet(); 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 // ========================================================================= /// /// Fake runtime initializer : configure Batik comme version détectée, /// sans contacter de base HFSQL. /// private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer { public Task 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()); } } /// /// Fake IClientService pour les tests HTTP : enregistre les appels /// et retourne des données contrôlées. /// 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? 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> SearchAsync( SearchClientsRequest request, CancellationToken cancellationToken = default) { SearchCallCount++; LastRequest = request; if (ThrowOnSearch is not null) throw ThrowOnSearch; return Task.FromResult(SearchResult ?? new PagedResult([], 1, 20, 0, 0)); } public Task> 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([], 1, 20, 0, 0)); } public Task GetByIdAsync( string id, CancellationToken cancellationToken = default) { DetailsCallCount++; LastDetailsId = id; if (ThrowOnDetails is not null) throw ThrowOnDetails; return Task.FromResult(DetailsResult); } public Task CreateAsync( CreateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => throw new NotSupportedException(); public Task 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 ArchiveAsync(string id, CancellationToken cancellationToken = default) { ArchiveCallCount++; LastArchiveId = id; if (ThrowOnArchive is not null) throw ThrowOnArchive; return Task.FromResult(ArchiveResult); } } /// /// Fake IHfsqlConnection minimal pour les tests HTTP. /// 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 ExecuteScalarAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(null); public Task ExecuteNonQueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(0); public Task>> QueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult>>([]); public Task> GetColumnsAsync(string tableName, CancellationToken ct = default) => Task.FromResult>([]); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }