433 lines
17 KiB
C#
433 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests HTTP des endpoints Intervention. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
|
|
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
|
|
/// et un fake <see cref="IInterventionService"/> 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 InterventionEndpointsTests : IDisposable
|
|
{
|
|
private readonly FakeInterventionService _fakeService = new();
|
|
private readonly FakeMobileInterventionSearchService _fakeMobileSearch = new();
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
public InterventionEndpointsTests()
|
|
{
|
|
ApplicationRuntimeContext.Reset();
|
|
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Hfsql:Server"] = "test-server",
|
|
["Hfsql:DatabaseName"] = "test-db"
|
|
})
|
|
.Build();
|
|
services.AddSingleton<IConfiguration>(config);
|
|
|
|
services.RemoveAll<IDatabaseRuntimeInitializer>();
|
|
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
|
|
|
|
services.RemoveAll<IHfsqlConnection>();
|
|
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
|
|
|
|
services.RemoveAll<IInterventionService>();
|
|
services.AddScoped<IInterventionService>(_ => _fakeService);
|
|
|
|
services.RemoveAll<IMobileInterventionSearchService>();
|
|
services.AddScoped<IMobileInterventionSearchService>(_ => _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<InterventionDetailsDto>();
|
|
|
|
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<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
|
|
// =========================================================================
|
|
|
|
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
|
|
{
|
|
public Task<Result> 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<InterventionSummaryDto>? 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<PagedResult<InterventionSummaryDto>> SearchAsync(
|
|
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
SearchCallCount++;
|
|
LastRequest = request;
|
|
|
|
if (ThrowOnSearch is not null) throw ThrowOnSearch;
|
|
|
|
return Task.FromResult(SearchResult
|
|
?? new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
|
|
}
|
|
|
|
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
|
|
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
SearchCallCount++;
|
|
LastRequest = request;
|
|
|
|
if (ThrowOnSearch is not null) throw ThrowOnSearch;
|
|
|
|
return Task.FromResult(new PagedResult<InterventionDetailsDto>(
|
|
DetailsResult is null ? [] : [DetailsResult], 1, 20, DetailsResult is null ? 0 : 1, 1));
|
|
}
|
|
|
|
public Task<InterventionDetailsDto?> GetByIdAsync(
|
|
string id, CancellationToken cancellationToken = default)
|
|
{
|
|
DetailsCallCount++;
|
|
LastDetailsId = id;
|
|
|
|
if (ThrowOnDetails is not null) throw ThrowOnDetails;
|
|
|
|
return Task.FromResult(DetailsResult);
|
|
}
|
|
|
|
|
|
public Task<InterventionDetailsDto> 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<InterventionDetailsDto?> 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<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
|
{
|
|
ArchiveCallCount++;
|
|
LastArchiveId = id;
|
|
|
|
if (ThrowOnArchive is not null) throw ThrowOnArchive;
|
|
|
|
return Task.FromResult(ArchiveResult);
|
|
}
|
|
|
|
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
|
|
string interventionId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
|
|
|
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
|
|
|
public Task<NoteInterneDto> AddNoteAsync(
|
|
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new NoteInterneDto());
|
|
|
|
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
|
|
string interventionId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
|
|
|
|
public Task<EmailInterventionDto> 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<IReadOnlyList<MobileInterventionSearchResult>> SearchAsync(MobileInterventionSearchRequest request, MobileTokenIdentity? identity, IReadOnlyDictionary<string, bool> permissions, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
|
|
|
|
public Task<IReadOnlyList<MobileInterventionSearchResult>> GetByIdsAsync(
|
|
IReadOnlyList<string> ids, CancellationToken cancellationToken = default)
|
|
{
|
|
GetByIdsCallCount++;
|
|
return Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
|
|
}
|
|
|
|
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(
|
|
string interventionId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>([]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|