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.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Mobile.Interventions; using Omika.Core.Contracts.Planning; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; 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 Intervention. 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 InterventionEndpointsTests : IDisposable { private readonly FakeInterventionService _fakeService = new(); private readonly FakeMobileInterventionSearchService _fakeMobileSearch = new(); private readonly WebApplicationFactory _factory; public InterventionEndpointsTests() { ApplicationRuntimeContext.Reset(); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Hfsql:Server"] = "test-server", ["Hfsql:DatabaseName"] = "test-db" }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => _fakeService); services.RemoveAll(); services.AddScoped(_ => _fakeMobileSearch); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // ========================================================================= // GET /interventions/{id} // ========================================================================= [Fact] public async Task GetInterventionById_NumericId_TransmittedAsString() { var client = _factory.CreateClient(); await client.GetAsync("/interventions/12345"); Assert.Equal("12345", _fakeService.LastDetailsId); } [Fact] public async Task GetInterventionById_GuidId_TransmittedAsString() { var client = _factory.CreateClient(); var guid = Guid.NewGuid().ToString("D"); await client.GetAsync($"/interventions/{guid}"); Assert.Equal(guid, _fakeService.LastDetailsId); } [Fact] public async Task GetInterventionById_Returns200WithDto() { _fakeService.DetailsResult = new InterventionDetailsDto { Id = "test-id", Reference = "INT-001", Titre = "Réparation" }; var client = _factory.CreateClient(); var response = await client.GetAsync("/interventions/42"); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(200, (int)response.StatusCode); Assert.NotNull(body); Assert.Equal("test-id", body.Id); Assert.Equal("INT-001", body.Reference); Assert.Equal("Réparation", body.Titre); } [Fact] public async Task GetInterventionById_NullResult_Returns404() { _fakeService.DetailsResult = null; var client = _factory.CreateClient(); var response = await client.GetAsync("/interventions/999"); Assert.Equal(404, (int)response.StatusCode); } [Fact] public async Task GetInterventionById_ServiceException_NotTransformedTo404() { _fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error"); var client = _factory.CreateClient(); var response = await client.GetAsync("/interventions/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); } // ========================================================================= // MOBILE SEARCH / BY-IDS — route enregistrée sous /api/interventions // ========================================================================= [Fact] public async Task MobileSearch_RouteRegistered_Returns401WithoutToken_Not404() { var client = _factory.CreateClient(); // Sans token, l'endpoint authentifié renvoie 401. S'il renvoyait 404, // c'est que la route /api/interventions/search n'est pas enregistrée. var response = await client.PostAsJsonAsync( "/api/interventions/search", new MobileInterventionSearchRequest()); Assert.Equal(401, (int)response.StatusCode); Assert.Equal(0, _fakeMobileSearch.SearchCallCount); } [Fact] public async Task MobileByIds_RouteRegistered_Returns401WithoutToken_Not404() { var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/interventions/by-ids", new MobileInterventionsByIdsRequest { Ids = [] }); Assert.Equal(401, (int)response.StatusCode); Assert.Equal(0, _fakeMobileSearch.GetByIdsCallCount); } // ========================================================================= // ARCHITECTURE // ========================================================================= [Fact] public void Endpoints_DependsOnlyOnIInterventionService() { var type = typeof(Omika.Api.Endpoints.InterventionEndpoints); var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static); foreach (var method in methods) { var parameters = method.GetParameters(); Assert.DoesNotContain(parameters, p => p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase) && p.ParameterType.Name != "IInterventionService"); } } [Fact] public void Endpoints_HasNoOmikaDataReferences() { var type = typeof(Omika.Api.Endpoints.InterventionEndpoints); var refs = GetReferencedNamespaces(type); Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data")); } [Fact] public void Endpoints_HasNoApplicationRuntimeContext() { var type = typeof(Omika.Api.Endpoints.InterventionEndpoints); var refs = GetReferencedNamespaces(type); Assert.DoesNotContain(refs, ns => ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Endpoints_HasNoHfsqlRowReferences() { var type = typeof(Omika.Api.Endpoints.InterventionEndpoints); 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 // ========================================================================= private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer { public Task InitializeAsync(CancellationToken cancellationToken = default) { ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); return Task.FromResult(Result.Success()); } } private sealed class FakeInterventionService : IInterventionService { public int SearchCallCount { get; private set; } public int DetailsCallCount { get; private set; } public int CreateCallCount { get; private set; } public int UpdateCallCount { get; private set; } public int ArchiveCallCount { get; private set; } public SearchInterventionsRequest? LastRequest { get; private set; } public string? LastDetailsId { get; private set; } public CreateInterventionRequest? LastCreateRequest { get; private set; } public string? LastUpdateId { get; private set; } public UpdateInterventionRequest? LastUpdateRequest { get; private set; } public string? LastArchiveId { get; private set; } public PagedResult? SearchResult { get; set; } public InterventionDetailsDto? DetailsResult { get; set; } public InterventionDetailsDto? CreateResult { get; set; } public InterventionDetailsDto? UpdateResult { get; set; } public bool ArchiveResult { get; set; } public Exception? ThrowOnSearch { get; set; } public Exception? ThrowOnDetails { get; set; } public Exception? ThrowOnCreate { get; set; } public Exception? ThrowOnUpdate { get; set; } public Exception? ThrowOnArchive { get; set; } public Task> SearchAsync( SearchInterventionsRequest 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( SearchInterventionsRequest request, CancellationToken cancellationToken = default) { SearchCallCount++; LastRequest = request; if (ThrowOnSearch is not null) throw ThrowOnSearch; return Task.FromResult(new PagedResult( DetailsResult is null ? [] : [DetailsResult], 1, 20, DetailsResult is null ? 0 : 1, 1)); } 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( CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) { CreateCallCount++; LastCreateRequest = request; if (ThrowOnCreate is not null) throw ThrowOnCreate; return Task.FromResult(CreateResult ?? new InterventionDetailsDto { Id = "new" }); } public Task UpdateAsync( string id, UpdateInterventionRequest 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); } public Task> GetNotesAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync( string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new NoteInterneDto()); public Task> GetEmailsAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddEmailAsync( string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new EmailInterventionDto()); } private sealed class FakeMobileInterventionSearchService : IMobileInterventionSearchService { public int SearchCallCount { get; private set; } public int GetByIdsCallCount { get; private set; } public Task> SearchAsync(MobileInterventionSearchRequest request, MobileTokenIdentity? identity, IReadOnlyDictionary permissions, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetByIdsAsync( IReadOnlyList ids, CancellationToken cancellationToken = default) { GetByIdsCallCount++; return Task.FromResult>([]); } public Task> GetRendezVousAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); } 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; } }