Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,624 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Domain.Entities.Mobile;
using Omika.Core.Domain.Entities.Security;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Back-Office WinDev
/// (POST /api/backoffice/auth/token).
///
/// Vérifie le contrat complet : codes HTTP, émission du Bearer token via le
/// mécanisme normal (ITokenService / MobileAuthMiddleware), contrôle d'accès
/// Back-Office (CO_AccesBO / UT_AccesBO), code client, et séparation avec le
/// droit A.LIC.MOB (qui n'est pas une condition d'émission du token).
///
/// Le hash utilisé est un placeholder volontairement faux : aucun vrai hash ni
/// vrai token n'apparaît dans ces tests.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class BackOfficeAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string ValidLogin = "DFO";
private const string ValidHash = "FAKE-HASH-0123456789ABCDEF0123456789ABCDEF";
private const long ValidUserId = 42;
private const long ValidCollaborateurId = 7;
private readonly FakeBackOfficeAuthRepository _authRepository = new();
private readonly FakeLicenceRepository _licenceRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public BackOfficeAuthEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IBackOfficeAuthRepository>();
services.AddScoped<IBackOfficeAuthRepository>(_ => _authRepository);
services.RemoveAll<ILicenceRepository>();
services.AddScoped<ILicenceRepository>(_ => _licenceRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== 1. Authentification réussie =====
[Fact]
public async Task Post_ValidCredentials_WithBoAccess_Returns200_AndValidToken()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
// Le token est valide et porte l'identité attendue (même mécanisme que le mobile).
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
MobileTokenIdentity identity = tokenService.ValidateToken(body.AccessToken)!;
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(ValidUserId.ToString(), identity.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), identity.CollaborateurId);
}
[Fact]
public async Task Post_LoginIsCaseInsensitive_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "dfo", // minuscules : reproduit le UPPER(UT_Login) = UPPER(login) WinDev
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 2/3. Identifiants incorrects =====
[Fact]
public async Task Post_WrongLogin_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Post_WrongPasswordHash_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = "AUTRE-HASH-FAUX"
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
// ===== 4. Hash vide =====
[Fact]
public async Task Post_EmptyPasswordHash_Returns400()
{
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = string.Empty
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 5. Accès Back-Office refusé =====
[Fact]
public async Task Post_UserWithoutBoAccess_Returns403()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, ValidCollaborateurId, ValidCollaborateurId,
UtilisateurAccesBO: false, CollaborateurAccesBO: false, CollaborateurActif: true);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Post_UserWithoutCollaborateur_ButUtAccesBoTrue_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 6. Collaborateur lié =====
[Fact]
public async Task Post_LinkedCollaborateur_ReturnsCorrectUserIdAndCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
}
// ===== 7. Utilisateur sans collaborateur =====
[Fact]
public async Task Post_UserWithoutCollaborateur_ReturnsNullCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Null(body.CollaborateurId);
}
// ===== 8/9. Code client =====
[Fact]
public async Task Post_EmptyCodeClient_Returns400()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = string.Empty,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
[Fact]
public async Task Post_UnknownCodeClient_Returns400()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = null; // client inconnu côté serveur
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = "INCONNU.99",
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 10/11/12. Token accepté par MobileAuthMiddleware + A.LIC.MOB =====
[Fact]
public async Task Post_ReturnedToken_IsAcceptedByMobileAuthMiddleware_OnProtectedEndpoint()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
// 1. Obtention du token Back-Office.
var authResponse = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)authResponse.StatusCode);
var session = await authResponse.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Le token est accepté par MobileAuthMiddleware sur un endpoint protégé.
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var protectedResponse = await protectedClient.GetAsync(
$"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)protectedResponse.StatusCode);
}
[Fact]
public async Task Post_UserWithLicenceMobilePermission_CanAccessLicenceMobileEndpoint()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
string token = await ObtainTokenAsync();
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
Assert.Equal(CodeClient, _licenseService.LastCodeClient);
}
[Fact]
public async Task Post_UserWithoutLicenceMobilePermission_TokenCreated_ButLicenceMobileDenied()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
string token = await ObtainTokenAsync();
Assert.False(string.IsNullOrWhiteSpace(token));
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
// ===== 13. Sécurité : aucun hash/token exposé =====
[Fact]
public async Task ErrorResponses_DoNotContainPasswordHash()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(ValidHash, body);
Assert.DoesNotContain(ValidHash.ToLowerInvariant(), body);
}
// ===== Helpers =====
private async Task<string> ObtainTokenAsync()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
return session.AccessToken;
}
private static BackOfficeAuthUser ValidUser() => new(
ValidUserId,
ValidLogin,
CollaborateurId: ValidCollaborateurId,
CollaborateurPkId: ValidCollaborateurId,
UtilisateurAccesBO: false,
CollaborateurAccesBO: true,
CollaborateurActif: true);
private static ClientBase ValidClient() => new()
{
Id = 1,
Code = CodeClient,
Nom = "Société Test"
};
// ===== Fakes =====
private sealed class FakeBackOfficeAuthRepository : IBackOfficeAuthRepository
{
public string ValidLogin { get; set; } = string.Empty;
public string ValidHash { get; set; } = string.Empty;
public BackOfficeAuthUser? UserToReturn { get; set; }
public Task<BackOfficeAuthUser?> GetByLoginAndPasswordHashAsync(
string login,
string passwordHash,
CancellationToken cancellationToken = default)
{
bool loginMatches = string.Equals(login, ValidLogin, StringComparison.OrdinalIgnoreCase);
bool hashMatches = string.Equals(passwordHash, ValidHash, StringComparison.Ordinal);
return Task.FromResult(loginMatches && hashMatches ? UserToReturn : null);
}
}
private sealed class FakeLicenceRepository : ILicenceRepository
{
public ClientBase? ClientToReturn { get; set; }
public Task<ClientBase?> GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(ClientToReturn);
public Task<Licence?> GetByNumeroAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<string?> GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<Licence?> GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<bool> AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult<CollaborateurLicenceDto?>(null);
}
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -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;
}
}
@@ -0,0 +1,280 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints administratifs de licence mobile
/// (GET/POST /collaborateurs/{id}/licence-mobile[/assign|/release]).
/// Vérifie l'authentification (Bearer) et l'autorisation (droit A.LIC.MOB),
/// ainsi que le contrat de réponse pour l'attribution, la consultation et la
/// libération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class CollaborateurLicenceEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public CollaborateurLicenceEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Consultation (GET) =====
[Fact]
public async Task Get_SansToken_Retourne401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-001", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Attribution (POST assign) =====
[Fact]
public async Task Assign_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Assign_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.AssignResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-002", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Libération (POST release) =====
[Fact]
public async Task Release_DroitLicenceMobilePresent_Retourne204()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.ReleaseResult = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/release", content: null);
Assert.Equal(204, (int)response.StatusCode);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public CollaborateurLicenceDto? AssignResult { get; set; }
public bool ReleaseResult { get; set; } = true;
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(AssignResult);
}
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(ReleaseResult);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,272 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Omika.Api.Endpoints;
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.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Development local
/// (<c>POST /api/dev/session</c>).
///
/// Vérifie :
/// - qu'un token signé valide est émis quand <c>DevelopmentAuth:Enabled</c> est vrai ;
/// - que l'endpoint est refusé (403) quand <c>DevelopmentAuth:Enabled</c> est faux ;
/// - que le token émis est un vrai token signé par le TokenService normal
/// (même issuer/audience/signature), utilisable sur les endpoints protégés.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DevelopmentAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public DevelopmentAuthEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "true"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task DevSession_Enabled_ReturnsValidSignedToken()
{
var client = _factory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est un vrai token signé par le TokenService normal.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(DevelopmentAuthEndpoints.DevCodeClient, identity.CodeClient);
Assert.Equal(DevelopmentAuthEndpoints.DevUserId, identity.UserId);
Assert.Equal(DevelopmentAuthEndpoints.DevNumeroLicence, identity.NumeroLicence);
}
[Fact]
public async Task DevSession_Enabled_TokenAllowsProtectedEndpoint()
{
var client = _factory.CreateClient();
var sessionResponse = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)sessionResponse.StatusCode);
var session = await sessionResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
// Le token Development doit être accepté par l'authentification mobile
// normale (même mécanisme de signature). On vérifie sur un endpoint
// protégé : le bootstrap retourne 200 (pas 401).
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await protectedClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
[Fact]
public async Task DevSession_Disabled_ReturnsForbidden()
{
// Factory avec DevelopmentAuth:Enabled = false.
using var disabledFactory = 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",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "false"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
});
});
using (disabledFactory)
{
var client = disabledFactory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
// Endpoint non enregistré : 404 (route absente).
Assert.Equal(404, (int)response.StatusCode);
}
}
[Fact]
public async Task DevSession_WithoutCollaborateur_CollaborateurIdNull()
{
// Arrange : factory sans DevelopmentAuth:DevCollaborateurId.
var client = _factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : aucun identifiant Collaborateur inventé.
Assert.NotNull(identity);
Assert.Null(identity.CollaborateurId);
}
[Fact]
public async Task DevSession_WithCollaborateur_IdentityContainsCollaborateurId()
{
// Arrange : factory avec DevelopmentAuth:DevCollaborateurId renseigné.
const string collaborateurId = "bbbbbbbb-0000-0000-0000-000000000042";
using var 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",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "true",
["DevelopmentAuth:DevCollaborateurId"] = collaborateurId
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
using (factory)
{
var client = factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : l'identité porte le vrai CollaborateurId configuré.
Assert.NotNull(identity);
Assert.Equal(collaborateurId, identity.CollaborateurId);
}
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,484 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
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.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tâche 13-C — Intégration du contexte de déploiement dans Omika.Api :
/// <list type="bullet">
/// <item>C1 : événement API interne → contexte du provider (ClientCode serveur).</item>
/// <item>C2/C3 : diagnostic mobile → ClientCode = JWT authentifié (autoritaire), jamais payload/provider.</item>
/// <item>C4/C5 : ServerInstance/Environment mobile = serveur ingérant (provider), jamais le téléphone.</item>
/// <item>C6 : payload falsifié (ClientId/ClientCode) ne remplace jamais le JWT.</item>
/// <item>C7 : requêtes concurrentes → aucune contamination inter-requêtes.</item>
/// <item>C8 : provider sans ClientCode → null sans exception (fail-open).</item>
/// <item>C9 : aucun secret dans ce que les producteurs transmettent.</item>
/// </list>
/// Provider et store sont des fakes en mémoire : l'objectif est de vérifier QUE le
/// bon <see cref="DiagnosticDeploymentContext"/> est transmis à
/// <see cref="IRemoteDiagnosticIngestionStore.StoreAsync"/> (le provider lui-même est
/// couvert par les tests D1–D16 de T13-D ; le JSONL par B1–B24 de T13-B).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeploymentContextApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeploymentContextApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Store fake : capture le contexte de déploiement transmis.
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
// Provider fake : contexte serveur déterministe (pas de HFSQL).
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// Active le forwarder serveur (4xx/5xx) pour C1/C8.
services.Configure<ServerDiagnosticOptions>(o => o.Enabled = true);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- C1 : événement API interne -----------------------
[Fact]
public async Task C1_InternalApiEvent_UsesProviderContext()
{
var client = _factory.CreateClient();
// 404 (hors /health et /api/diagnostics) → le ServerDiagnosticMiddleware
// enregistre un événement API_HTTP_4XX avec le contexte du provider.
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
AssertContext(call.Context, clientCode: "CLIENT-SERVEUR", serverInstance: "API-SRV-01", environment: "Production");
}
// --------------------------- C2 : diagnostic mobile ---------------------------
[Fact]
public async Task C2_MobileDiagnostic_UsesJwtCodeClientOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload mobile : ClientId falsifiable = FAUX-CLIENT.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C3 : priorité JWT > provider > payload -----------
[Fact]
public async Task C3_MobileDiagnostic_PriorityJwtOverProviderOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
// JWT gagne ; le provider ("CLIENT-SERVEUR") ne gagne PAS pour le mobile ;
// le payload ("FAUX-CLIENT") n'est JAMAIS candidat.
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
Assert.NotEqual("CLIENT-SERVEUR", call.Context.ClientCode);
Assert.NotEqual("FAUX-CLIENT", call.Context.ClientCode);
}
// --------------------------- C4 : ServerInstance mobile = provider ------------
[Fact]
public async Task C4_MobileDiagnostic_ServerInstanceFromProviderNotPhone()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// DeviceModel du téléphone : ne doit jamais devenir ServerInstance.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(deviceModel: "iPhone 15"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("API-SRV-01", call.Context!.ServerInstance);
Assert.NotEqual("iPhone 15", call.Context.ServerInstance);
}
// --------------------------- C5 : Environment mobile = provider ---------------
[Fact]
public async Task C5_MobileDiagnostic_EnvironmentFromProvider()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("Production", call.Context!.Environment);
}
// --------------------------- C6 : payload falsifié ----------------------------
[Fact]
public async Task C6_FalsifiedPayload_NeverReplacesJwtCodeClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload brut contenant clientId + un champ inconnu "clientCode" (ignoré par
// la désérialisation typée) : aucun des deux ne doit devenir le ClientCode.
string rawJson = $$"""
{
"schemaVersion": 1,
"batchId": "{{Guid.NewGuid():D}}",
"sentAtUtc": "2025-01-01T00:00:00+00:00",
"events": [
{
"eventId": "{{Guid.NewGuid():D}}",
"eventCode": "OMK-MOB-APP-001",
"timestampUtc": "2025-01-01T00:00:00+00:00",
"level": "Info",
"priority": "P3",
"clientId": "AUTRE-CLIENT",
"clientCode": "AUTRE-CLIENT"
}
]
}
""";
using var content = new StringContent(rawJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C7 : requêtes concurrentes -----------------------
[Fact]
public async Task C7_ConcurrentRequests_NoContextContamination()
{
var client = _factory.CreateClient();
(string Code, string EventCode)[] cases =
[
("CLIENT-A", "T7-A"),
("CLIENT-B", "T7-B"),
("CLIENT-C", "T7-C")
];
// Construction explicite des requêtes concurrentes avec des tokens distincts.
Task<HttpResponseMessage>[] requests = cases.Select(c =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/diagnostics")
{
Content = JsonContent.Create(ValidBatch(eventCode: c.EventCode))
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(c.Code));
return client.SendAsync(req);
}).ToArray();
HttpResponseMessage[] responses = await Task.WhenAll(requests);
Assert.All(responses, r => Assert.Equal(200, (int)r.StatusCode));
Assert.Equal(3, _store.Calls.Count);
foreach ((string Code, string EventCode) c in cases)
{
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call =
Assert.Single(_store.Calls, x => x.Batch.Events[0].EventCode == c.EventCode);
Assert.Equal(c.Code, call.Context!.ClientCode);
// ServerInstance/Environment identiques pour tous les clients.
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
}
// --------------------------- C8 : provider sans ClientCode --------------------
[Fact]
public async Task C8_ProviderWithoutClientCode_NullWithoutException()
{
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = null,
ServerInstance = "API-SRV-01",
Environment = "Production"
};
var client = _factory.CreateClient();
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
// Fail-open : le 404 est toujours produit, l'événement est enregistré avec
// ClientCode = null, aucune exception.
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Null(call.Context!.ClientCode);
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- C9 : absence de secret ---------------------------
[Fact]
public async Task C9_NoSecretsInProducedContextOrBatch()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
string contextJson = JsonSerializer.Serialize(call.Context!);
string batchJson = JsonSerializer.Serialize(call.Batch);
foreach (string secret in new[] { "Authorization", "Bearer", "HmacKey", "ConnectionString", "password", "CL_CleAPIInstall" })
{
Assert.DoesNotContain(secret, contextJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secret, batchJson, StringComparison.OrdinalIgnoreCase);
}
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(
string eventCode = "OMK-MOB-APP-001",
string? clientId = null,
string? deviceModel = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId,
DeviceModel = deviceModel
}
]
};
private string CreateToken(string codeClient)
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = "device-test",
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = "licence-test"
});
}
private static void AssertContext(
DiagnosticDeploymentContext? context,
string? clientCode,
string? serverInstance,
string? environment)
{
Assert.NotNull(context);
Assert.Equal(clientCode, context!.ClientCode);
Assert.Equal(serverInstance, context.ServerInstance);
Assert.Equal(environment, context.Environment);
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls
{
get
{
lock (_gate)
{
return _calls.ToArray();
}
}
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
lock (_gate)
{
_calls.Add((batch, deploymentContext));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
/// <summary>
/// Tâche 13-C C10 — Régression : les anciens appels <c>StoreAsync</c> (sans
/// <c>deploymentContext</c>) et les anciens JSONL restent compatibles. Utilise le
/// vrai <see cref="RemoteDiagnosticIngestionStore"/> (aucun HTTP requis).
/// </summary>
public class DiagnosticDeploymentContextBackwardCompatibilityTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-t13c-compat", Guid.NewGuid().ToString("N"));
public DiagnosticDeploymentContextBackwardCompatibilityTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
[Fact]
public async Task C10_LegacyStoreAsyncCall_WritesJsonlWithoutContextFields()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var evt = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3"
};
var batch = new DiagnosticBatchDto { Events = [evt] };
// Appel hérité : AUCUN deploymentContext.
int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow);
Assert.Equal(1, accepted);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
using JsonDocument doc = JsonDocument.Parse(content);
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
}
private static string ReadAllTextShared(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
@@ -0,0 +1,318 @@
using System.Net;
using System.Net.Http.Json;
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.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tâche 17-B — Autorité serveur de LicenseNumber / DeviceKey dans Omika.Api :
/// <list type="bullet">
/// <item>D1 : LicenseNumber = NumeroLicence du token authentifié (jamais le payload).</item>
/// <item>D2 : DeviceKey = HMAC(CodeClient + DeviceId) calculé serveur (jamais choisi par le mobile).</item>
/// <item>D3 : un payload falsifié (deviceKey/licenseNumber/deviceId) ne remplace jamais l'identité serveur.</item>
/// <item>D4 : ClientCode reste autoritaire (protection T13-C intacte).</item>
/// <item>D5 : token sans numéro de licence → LicenseNumber null, aucun rejet (fail-open).</item>
/// </list>
/// Provider et store sont des fakes en mémoire ; la DeviceKey est calculée par le VRAI
/// <see cref="IDiagnosticDeviceKeyProvider"/> enregistré dans Program (clé de test déterministe).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeviceLicenseApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private static readonly string HmacKey = Convert.ToBase64String(new byte[32]);
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeviceLicenseApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_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",
["MobileAuth:SigningKey"] = SigningKey,
["DiagnosticsIdentity:HmacKey"] = HmacKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// DeviceKey provider déterministe (clé de test) : le provider réel de
// Program capture builder.Configuration (non surchargé ici), donc on
// remplace explicitement le provider pour obtenir un HMAC stable.
services.RemoveAll<IDiagnosticDeviceKeyProvider>();
services.AddSingleton<IDiagnosticDeviceKeyProvider>(new DiagnosticDeviceKeyProvider(HmacKey));
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- D1 : LicenseNumber = token ----------------------
[Fact]
public async Task D1_LicenseNumber_ComesFromToken()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: "LIC-AAAA"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("LIC-AAAA", call.LicenseNumber);
}
// --------------------------- D2 : DeviceKey = HMAC serveur -------------------
[Fact]
public async Task D2_DeviceKey_IsComputedServerSide()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
// La DeviceKey reçue doit être EXACTEMENT le HMAC serveur attendu, jamais une
// valeur transmise par le client (le DTO ne l'expose pas).
DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA");
Assert.True(expected.HasValue);
Assert.Equal(expected.Value, call.DeviceKey!.Value);
Assert.Equal(expected.Version, call.DeviceKey.Version);
}
// --------------------------- D3 : payload falsifié ignoré --------------------
[Fact]
public async Task D3_FalsifiedPayload_NeverReplacesDeviceOrLicenseIdentity()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA", numeroLicence: "LIC-AAAA"));
// Le payload tente d'injecter deviceId / deviceKey / licenseNumber : aucun de
// ces champs n'est un champ structuré du DTO (deviceId est épuré, deviceKey et
// licenseNumber sont ignorés à la désérialisation).
string rawJson = $$"""
{
"schemaVersion": 1,
"batchId": "{{Guid.NewGuid():D}}",
"sentAtUtc": "2025-01-01T00:00:00+00:00",
"events": [
{
"eventId": "{{Guid.NewGuid():D}}",
"eventCode": "OMK-MOB-APP-001",
"timestampUtc": "2025-01-01T00:00:00+00:00",
"level": "Info",
"priority": "P3",
"deviceId": "FAUX-DEVICE",
"deviceKey": "FAUX-DEVICE-KEY",
"licenseNumber": "FAUX-LICENCE"
}
]
}
""";
using var content = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("LIC-AAAA", call.LicenseNumber); // token, pas FAUX-LICENCE
DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA");
Assert.Equal(expected.Value, call.DeviceKey!.Value); // HMAC serveur, pas FAUX-DEVICE-KEY
Assert.DoesNotContain("FAUX-DEVICE-KEY", call.DeviceKey.Value, StringComparison.Ordinal);
}
// --------------------------- D4 : ClientCode reste autoritaire ---------------
[Fact]
public async Task D4_ClientCode_ProtectionStillIntact()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- D5 : token sans licence → null, pas de rejet -----
[Fact]
public async Task D5_TokenWithoutLicense_NullLicenseNumber_NoRejection()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: ""));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Null(call.LicenseNumber);
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(string? clientId = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId
}
]
};
private string CreateToken(string codeClient, string deviceId = "device-test", string numeroLicence = "licence-test")
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = deviceId,
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = numeroLicence
});
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> Calls
{
get
{
lock (_gate)
{
return _calls.ToArray();
}
}
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
lock (_gate)
{
_calls.Add((batch, deploymentContext, deviceKey, licenseNumber));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,83 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
using Omika.Api;
namespace Omika.Tests.Api;
/// <summary>
/// Tests de la configuration ForwardedHeaders (reverse proxy Apache local).
/// Vérifient le contenu exact des options (en-têtes, ForwardLimit, KnownProxies)
/// et l'absence de toute configuration « trust-all » (symétrie conservée,
/// KnownNetworks jamais vidé, aucun proxy non-loopback).
/// </summary>
public class ForwardedHeadersConfigurationTests
{
[Fact]
public void CreateOptions_ConfiguresForwardedHeaders()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(
ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost,
options.ForwardedHeaders);
}
[Fact]
public void CreateOptions_SetsForwardLimitToOne()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(1, options.ForwardLimit);
}
[Fact]
public void CreateOptions_AddsIpv4LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_AddsIpv6LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.IPv6Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_OnlyTrustsLoopbackProxies()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// Aucun wildcard (0.0.0.0 / [::]) : uniquement le proxy Apache local.
Assert.DoesNotContain(IPAddress.Any, options.KnownProxies);
Assert.DoesNotContain(IPAddress.IPv6Any, options.KnownProxies);
Assert.All(options.KnownProxies, ip => Assert.True(IPAddress.IsLoopback(ip)));
}
[Fact]
public void CreateOptions_KeepsRequireHeaderSymmetryEnabled()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// La symétrie des en-têtes reste activée (défaut sécurisé) : jamais
// désactivée pour une configuration permissive globale.
Assert.True(options.RequireHeaderSymmetry);
}
[Fact]
public void CreateOptions_DoesNotClearKnownNetworks()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// KnownIPNetworks (anciennement KnownNetworks) conserve ses réseaux par
// défaut (loopback) : jamais vidé.
Assert.NotEmpty(options.KnownIPNetworks);
// La confiance réseau reste bornée au loopback (127.0.0.1/8 et ::1/128).
Assert.All(options.KnownIPNetworks, network => Assert.True(IPAddress.IsLoopback(network.BaseAddress)));
}
}
@@ -0,0 +1,458 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Documents;
using Omika.Core.Domain;
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;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints « courriel envoyé depuis une MESSAGERIE EXTERNE » :
/// <list type="bullet">
/// <item><c>POST /api/interventions/{id}/emails/envoye-externe</c> ;</item>
/// <item><c>PUT /api/interventions/{id}/emails/{emailId}/envoye-externe</c>.</item>
/// </list>
/// Ces endpoints n'envoient RIEN : ils enregistrent un courriel déclaré envoyé
/// par l'utilisateur depuis sa propre messagerie
/// (<c>ME_Envoyer=true</c>, <c>ME_EnvoyerMob=true</c>, <c>ME_Brouillon=false</c>),
/// et refusent l'appel quand la messagerie interne (Back-Office) est active.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class InterventionEmailExterneEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private const string InterventionId = "12345";
private readonly FakeInterventionService _interventionService = new();
private readonly FakePermissionService _permissionService = new();
private readonly FakeSmtpConfigurationService _smtpService = new();
private readonly WebApplicationFactory<Program> _factory;
public InterventionEmailExterneEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.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>(_ => _interventionService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
services.RemoveAll<ISmtpConfigurationService>();
services.AddScoped<ISmtpConfigurationService>(_ => _smtpService);
services.RemoveAll<IMobileDocumentService>();
services.AddScoped<IMobileDocumentService, FakeMobileDocumentService>();
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Authentification / autorisation =====
/// <summary>Sans token : refusé avant toute écriture.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansToken_Retourne401()
{
var client = _factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sans le droit d'envoi d'email : refusé.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansDroitEnvoiEmail_Retourne403()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : enregistrement « envoyé » =====
/// <summary>
/// Messagerie externe : le courriel est enregistré avec les champs d'un
/// courriel ENVOYÉ (ME_Envoyer + ME_EnvoyerMob), sans envoi SMTP.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieExterne_EnregistreEnEnvoyeMob()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.False(enregistrement!.Brouillon);
Assert.True(enregistrement.Envoyer);
Assert.True(enregistrement.EnvoyerMob);
// Aucun expéditeur connu : l'envoi n'est pas passé par un compte Omika.
Assert.Null(enregistrement.AdrExp);
Assert.Equal("Intervention du jour", enregistrement.Objet);
}
/// <summary>
/// Messagerie interne (Back-Office) : l'endpoint refuse — l'envoi doit passer
/// par Omika, jamais être « déclaré » envoyé par le mobile.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieInterne_Retourne400EtRienNEstEcrit()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sujet et message vides : refusé (même règle que les autres endpoints).</summary>
[Fact]
public async Task PostEnvoyeExterne_SansSujetNiMessage_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
new CreateEmailInterventionRequest { Objet = " ", Message = " " });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : brouillon existant devenu envoyé =====
/// <summary>Un brouillon existant est marqué envoyé (même état que la création).</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieExterne_MarqueLeBrouillonEnvoye()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("email-42", _interventionService.DernierEmailId);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.True(enregistrement!.EnvoyerMob);
Assert.False(enregistrement.Brouillon);
}
/// <summary>Un courriel déjà envoyé ne peut pas être marqué une seconde fois (409).</summary>
[Fact]
public async Task PutEnvoyeExterne_CourrielDejaEnvoye_Retourne409()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
_interventionService.ErreurMajEmail = "Ce courriel a déjà été envoyé : il ne peut plus être modifié.";
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
/// <summary>Messagerie interne : le marquage externe est refusé.</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieInterne_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Aides =====
private static CreateEmailInterventionRequest Demande()
=> new()
{
Objet = "Intervention du jour",
Message = "Bonjour,\nIntervention terminée.",
AdrDestinataires = "client@exemple.fr",
AdrCC = "copie@exemple.fr"
};
private HttpClient ClientAuthentifie()
{
HttpClient client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", CreateValidToken());
return client;
}
private string CreateValidToken()
{
using IServiceScope scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeInterventionService : IInterventionService
{
public EmailEnregistrement? DernierEnregistrement { get; private set; }
public string? DernierEmailId { get; private set; }
/// <summary>Simule un courriel déjà envoyé (409).</summary>
public string? ErreurMajEmail { get; set; }
public Task<EmailInterventionDto> EnregistrerNouvelEmailAsync(
string interventionId,
EmailEnregistrement enregistrement,
AuditActor? auteur,
CancellationToken cancellationToken = default)
{
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = "email-nouveau" });
}
public Task<EmailInterventionDto> EnregistrerEmailExistantAsync(
string emailId,
EmailEnregistrement enregistrement,
CancellationToken cancellationToken = default)
{
if (ErreurMajEmail is not null)
{
throw new InvalidOperationException(ErreurMajEmail);
}
DernierEmailId = emailId;
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = emailId });
}
// ---- Membres de IInterventionService non exercés par ces tests ----
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionDetailsDto>([], 1, 20, 0, 0));
public Task<InterventionDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<InterventionDetailsDto> CreateAsync(
CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult(new InterventionDetailsDto { Id = "new" });
public Task<InterventionDetailsDto?> UpdateAsync(
string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
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 FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeSmtpConfigurationService : ISmtpConfigurationService
{
/// <summary>Préférence de messagerie simulée (false = Back-Office interne).</summary>
public bool MessagerieExterne { get; set; }
public Task<IReadOnlyList<SmtpConfigurationDto>> GetListAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SmtpConfigurationDto>>([]);
public Task<SmtpConfiguration?> GetAsync(
string collaborateurId,
string configurationId,
CancellationToken cancellationToken = default)
=> Task.FromResult<SmtpConfiguration?>(null);
public Task<bool> EstMessagerieExterneAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(MessagerieExterne);
}
private sealed class FakeMobileDocumentService : IMobileDocumentService
{
public Task<DocumentDownloadResponse?> DownloadAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<DocumentDownloadResponse?>(null);
public Task<long> ResolveDocumentPkAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task<string?> ResolveDocumentFullPathAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<DocumentUploadResponse> UploadAsync(
DocumentUploadRequest request, string categorieCle, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionAsync(
string interventionId, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionsAsync(
IReadOnlyList<long> numericInterventionIds, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
Guid documentGuid, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,432 @@
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;
}
}
@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'API dédiée de téléchargement des modèles de langue (ONNX).
/// Accès anonyme : vérifie le manifeste, le streaming de fichier et les 404.
/// </summary>
public class LanguageEndpointsTests : IDisposable
{
private const string ManifestJson = """
{
"packs": [
{
"code": "es",
"libelle": "Espagnol",
"version": "1.0.0",
"fichiers": [
{ "sens": "versFrancais", "nomFichier": "encoder.onnx", "tailleOctets": 16, "sha256": "abc" }
]
}
]
}
""";
private readonly string _root;
private readonly WebApplicationFactory<Omika.Langues.Api.Program> _factory;
public LanguageEndpointsTests()
{
_root = CreateRoot();
Populate(_root);
_factory = CreateFactory(_root);
}
public void Dispose()
{
_factory.Dispose();
Directory.Delete(_root, true);
}
[Fact]
public async Task Langues_Manifest_RetournePacksEtUrls()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(200, (int)response.StatusCode);
var manifest = await response.Content.ReadFromJsonAsync<LanguageManifestResponse>();
Assert.NotNull(manifest);
var pack = Assert.Single(manifest.Packs);
Assert.Equal("es", pack.Code);
Assert.Equal("Espagnol", pack.Libelle);
var fichier = Assert.Single(pack.Fichiers);
Assert.Equal("/api/langues/es/versFrancais/encoder.onnx", fichier.Url);
}
[Fact]
public async Task Langues_Fichier_RetourneContenu()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/encoder.onnx");
Assert.Equal(200, (int)response.StatusCode);
string content = await response.Content.ReadAsStringAsync();
Assert.Equal("fake-model-bytes", content);
}
[Fact]
public async Task Langues_Fichier_Introuvable_Retourne404()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/absent.onnx");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task Langues_ManifestAbsent_Retourne404()
{
string emptyRoot = CreateRoot();
using var emptyFactory = CreateFactory(emptyRoot);
try
{
var client = emptyFactory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(404, (int)response.StatusCode);
}
finally
{
Directory.Delete(emptyRoot, true);
}
}
// ===== Helpers =====
private static string CreateRoot()
{
string root = Path.Combine(Path.GetTempPath(), "omika-langues-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static void Populate(string root)
{
Directory.CreateDirectory(Path.Combine(root, "es", "versFrancais"));
File.WriteAllText(Path.Combine(root, "manifest.json"), ManifestJson);
File.WriteAllText(Path.Combine(root, "es", "versFrancais", "encoder.onnx"), "fake-model-bytes");
}
private static WebApplicationFactory<Omika.Langues.Api.Program> CreateFactory(string root)
=> new WebApplicationFactory<Omika.Langues.Api.Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Langues:RootPath"] = root,
["Langues:ManifestFileName"] = "manifest.json"
})
.Build();
services.AddSingleton<IConfiguration>(config);
});
});
}
@@ -0,0 +1,380 @@
using System.Net;
using System.Net.Http.Json;
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.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de licence mobile.
/// Vérifie l'émission du Bearer token après activation/validation réussie,
/// l'absence de token en cas d'échec, la chaîne activation → bootstrap,
/// et le renouvellement du token après validation.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class LicenseEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3";
private const string DeviceId = "device-abc-123";
private readonly FakeLicenseService _licenseService = new();
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public LicenseEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== ACTIVATE =====
[Fact]
public async Task Activate_Success_ReturnsToken_ValidIdentity_AndExpiration()
{
_licenseService.ActivateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est valide et porte l'identité attendue.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal("42", identity.UserId);
Assert.Equal("7", identity.CollaborateurId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// L'expiration retournée est cohérente avec la durée de vie configurée (24 h).
var expected = tokenService.GetTokenExpirationUtc();
Assert.True(body.AccessTokenExpiresAtUtc <= expected.AddMinutes(1));
Assert.True(body.AccessTokenExpiresAtUtc >= expected.AddMinutes(-1));
}
[Fact]
public async Task Activate_Failure_ReturnsNoToken()
{
_licenseService.ActivateResult = InvalidLicenseResponse(LicenseErrorCodes.LicenceInconnue);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(LicenseErrorCodes.LicenceInconnue, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== VALIDATE =====
[Fact]
public async Task Validate_Success_ReturnsNewToken()
{
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
}
[Theory]
[InlineData(LicenseErrorCodes.LicenceInconnue)]
[InlineData(LicenseErrorCodes.LicenceInactive)]
[InlineData(LicenseErrorCodes.LicenceExpiree)]
[InlineData(LicenseErrorCodes.LicenceAttribuee)]
public async Task Validate_Failure_ReturnsNoToken(string errorCode)
{
_licenseService.ValidateResult = InvalidLicenseResponse(errorCode);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(errorCode, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== CHAÎNE COMPLÈTE : activation → bootstrap =====
[Fact]
public async Task Activate_Then_Bootstrap_WithBearer_Returns200()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_bootstrapService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "42", Login = "user-42" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
// 1. Activation → récupération du Bearer token.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)activateResponse.StatusCode);
var session = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Bootstrap avec le vrai format Bearer.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
var bootstrap = await bootstrapResponse.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(bootstrap);
Assert.Equal("42", bootstrap.Utilisateur.Id);
}
// ===== RENOUVELLEMENT APRÈS EXPIRATION (conceptuel) =====
[Fact]
public async Task Validate_After_Activate_Produces_New_Valid_Token()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
// T1 via activation.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
var session1 = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session1);
// T2 via validation (renouvellement).
var validateResponse = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)validateResponse.StatusCode);
var session2 = await validateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session2);
// T2 est un nouveau token, valide, avec une nouvelle expiration.
Assert.False(string.IsNullOrWhiteSpace(session2.AccessToken));
Assert.True(session2.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(session2.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// Le nouveau token permet l'accès aux endpoints protégés.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session2.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
// ===== Helpers =====
private static LicenseResponse ValidLicenseResponse() => new()
{
IsValid = true,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
NomSociete = "Société Test",
UserNom = "Dupont",
UserPrenom = "Jean",
UserId = "42",
CollaborateurId = "7",
OfflineGraceDays = 10,
ServerTimeUtc = DateTimeOffset.UtcNow,
ErrorCode = LicenseErrorCodes.Ok
};
private static LicenseResponse InvalidLicenseResponse(string errorCode) => new()
{
IsValid = false,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
ErrorCode = errorCode,
ErrorMessage = "Licence invalide",
ServerTimeUtc = DateTimeOffset.UtcNow
};
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public LicenseResponse ActivateResult { get; set; } = new();
public LicenseResponse ValidateResult { get; set; } = new();
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ActivateResult);
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ValidateResult);
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,185 @@
using System.Net;
using System.Net.Http.Json;
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.Mobile.Bootstrap;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de bootstrap mobile.
/// Vérifie l'authentification (sans token, token invalide) et le contrat retourné.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobileBootstrapEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobileBootstrapEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Bootstrap_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_InvalidToken_Returns401()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid.token.value");
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_DeviceMismatch_Returns403_Not500()
{
_fakeService.ThrowDeviceMismatch = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_ValidToken_Returns200WithContract()
{
_fakeService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "user-1", Login = "user-1" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(body);
Assert.Equal("user-1", body.Utilisateur.Id);
Assert.Equal("BATIK", body.Profil.Code);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public bool ThrowDeviceMismatch { get; set; }
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
if (ThrowDeviceMismatch)
{
throw new UnauthorizedAccessException(
"Le DeviceId authentifié ne correspond pas à la licence attribuée.");
}
return Task.FromResult(Result);
}
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,232 @@
using System.Net;
using System.Net.Http.Json;
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.Constants;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests d'intégration de la résolution réelle des permissions :
/// token → MobileAuthMiddleware → MobileTokenIdentity → IMobilePermissionService
/// → IMobileIdentityProvider → IMobileIdentityRepository → A.LIC.MOB.
///
/// Utilise le vrai <see cref="MobilePermissionService"/> et le vrai
/// <see cref="Omika.Core.Services.Mobile.MobileIdentityProvider"/>, avec un faux
/// repository d'identité. Vérifie qu'un grand identifiant 64 bits traverse la
/// chaîne sans troncature et que A.LIC.MOB est résolu dynamiquement.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobilePermissionResolutionIntegrationTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string CollaborateurId = "7";
private const string BigUserId = "9000000000"; // > Int32.MaxValue, aucun risque de troncature
private readonly FakeMobileIdentityRepository _identityRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobilePermissionResolutionIntegrationTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Réel IMobilePermissionService + IMobileIdentityProvider,
// avec un faux repository d'identité (résolution contrôlée).
services.RemoveAll<IMobileIdentityRepository>();
services.AddScoped<IMobileIdentityRepository>(_ => _identityRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task PermissionAccordee_EndpointLicenceNonRefuse_UserIdGrandIntact()
{
_identityRepository.GrantLicenceMobile = true;
_licenseService.GetResult = new CollaborateurLicenceDto { Numero = "LIC-001", CodeClient = CodeClient, Active = true };
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
// 200 (licence trouvée) ou 404 (licence absente), mais jamais 401 pour manque de droit.
Assert.NotEqual(401, (int)response.StatusCode);
// Le UserId du token (grand identifiant 64 bits) est transmis intact à la résolution.
Assert.Equal(BigUserId, _identityRepository.LastUserId);
}
[Fact]
public async Task PermissionRefusee_EndpointLicence_Retourne401()
{
_identityRepository.GrantLicenceMobile = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task PermissionAbsente_EndpointLicence_Retourne401()
{
// Aucune permission A.LIC.MOB dans le catalogue retourné.
_identityRepository.GrantLicenceMobile = null;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
private string CreateToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = CodeClient,
DeviceId = "backoffice-windev",
UserId = BigUserId,
CollaborateurId = CollaborateurId,
NumeroLicence = string.Empty
});
}
// ===== Fakes =====
private sealed class FakeMobileIdentityRepository : IMobileIdentityRepository
{
/// <summary>true = accordée, false = refusée, null = absente.</summary>
public bool? GrantLicenceMobile { get; set; }
public string? LastUserId { get; private set; }
public Task<MobileProfileDto> GetProfileAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult(new MobileProfileDto { Code = "ROLE", Nom = "ROLE" });
}
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
var permissions = MobilePermissionKeys.Empty();
if (GrantLicenceMobile is { } granted)
{
permissions[MobilePermissionKeys.LicenceMobile] = granted;
}
return Task.FromResult<IReadOnlyDictionary<string, bool>>(permissions);
}
public Task<string?> GetLoginByUserIdAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult<string?>("TEST_USER");
}
public Task<long?> GetUtilisateurPkIdByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<long?>(null);
public Task<string?> GetLoginByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(GetResult);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,298 @@
using Omika.Api.Services;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Organisation;
using Omika.Core.Contracts.Planning;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests du filtre de périmètre <see cref="RdvScopeService"/> : compatibilité
/// Omika (GUID + PK numérique résolu) sans régression Batik (comparaison par
/// PK numérique). Le contrôle des droits n'est jamais contourné : un RDV hors
/// périmètre reste filtré.
/// </summary>
public class RdvScopeServiceTests
{
// Le token porte le PK numérique du collaborateur (CO_PKID Omika), comme
// produit par LicenseService.BuildSuccessAsync.
private static readonly MobileTokenIdentity IdentityPk42 = new()
{
CodeClient = "CLI",
DeviceId = "dev",
UserId = "1",
CollaborateurId = "42"
};
[Fact]
public async Task Cas1_UtilisateurAvecAcces_RendezVousVisible()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas2_UtilisateurSansAcces_RendezVousFiltre()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task Cas3_DroitSocietePrincipale_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 100]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 100)],
IdentityPk42,
Droits(societePrincipale: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, true, false, false), service.LastCall);
}
[Fact]
public async Task Cas4_DroitSociete_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 200]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 200)],
IdentityPk42,
Droits(societes: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, true, false), service.LastCall);
}
[Fact]
public async Task Cas5_DroitEquipe_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 300]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 300)],
IdentityPk42,
Droits(equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, false, true), service.LastCall);
}
[Fact]
public async Task Cas6_CollaborateurOmikaIdentifieParGuid_RendezVousAutoriseVisible()
{
// Modèle Omika : l'affectation porte le GUID ET le PK numérique résolu.
// Le RDV autorisé ne doit plus disparaître.
Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e");
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42, guid: guid)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7_Batik_DansPerimetre_Visible()
{
// Affectation Batik : référence numérique uniquement (FromLegacyId).
// Le collaborateur courant (PK 42) est toujours inclus → visible.
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "42",
SourceReference = LegacyEntityReference.FromLegacyId(42)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7b_Batik_HorsPerimetre_Filtre()
{
// Comportement attendu Batik (aligné sur MobileBootstrapService) : sans les
// 3 droits A.C.PL.*, un RDV affecté à un collaborateur hors périmètre est
// filtré (le filtre par PK numérique s'applique). Avant le correctif, ce
// cas était silencieusement fail-open (RDV visible à tort).
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "99",
SourceReference = LegacyEntityReference.FromLegacyId(99)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task TroisDroits_AucunFiltre()
{
// Les 3 droits présents → aucune restriction (comportement historique).
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(societePrincipale: true, societes: true, equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Null(service.LastCall);
}
private static RendezVousDetailsDto RdvOmika(long numericId, Guid? guid = null)
=> new()
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = guid?.ToString("D") ?? numericId.ToString(),
SourceReference = new LegacyEntityReference(
DatabaseSchemaVersion.Omika,
NumericId: numericId,
EntityGuid: guid)
}
]
};
private static IReadOnlyDictionary<string, bool> Droits(
bool societePrincipale = false,
bool societes = false,
bool equipe = false)
=> new Dictionary<string, bool>
{
[MobilePermissionKeys.PlanningSocietePrincipale] = societePrincipale,
[MobilePermissionKeys.PlanningSocietes] = societes,
[MobilePermissionKeys.PlanningEquipe] = equipe
};
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
{
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
=> physical.Guid ?? Guid.NewGuid();
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
=> new PhysicalEntityIdentity { Guid = publicGuid };
public bool IsCanonical(string entityType, Guid publicGuid)
=> publicGuid != Guid.Empty;
}
private sealed class FakeCollaborateurService : ICollaborateurService
{
private readonly IReadOnlyList<long> _allowed;
public FakeCollaborateurService(IReadOnlyList<long> allowed)
=> _allowed = allowed;
public (long CollaborateurId, bool SocietePrincipale, bool Societes, bool Equipe)? LastCall { get; private set; }
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(
long collaborateurId,
bool includeSocietePrincipale,
bool includeSocietes,
bool includeEquipe,
CancellationToken cancellationToken = default)
{
LastCall = (collaborateurId, includeSocietePrincipale, includeSocietes, includeEquipe);
return Task.FromResult(_allowed);
}
public Task<PagedResult<CollaborateurSummaryDto>> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<PagedResult<CollaborateurDetailsDto>> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousForCollaborateursAsync(IReadOnlyList<string> collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,118 @@
namespace Omika.Tests.Api;
/// <summary>
/// Tests de sécurité du reverse proxy Apache local : analyse la source
/// d'Omika.Api (Program.cs + ForwardedHeadersConfiguration.cs) pour garantir
/// l'ordre du middleware, l'absence de configuration « trust-all », l'absence
/// de certificat Kestrel et l'absence de bind sur 0.0.0.0.
/// </summary>
public class ReverseProxySecurityTests
{
private const string ProgramFile = "src/Omika.Api/Program.cs";
private const string ForwardedHeadersFile = "src/Omika.Api/ForwardedHeadersConfiguration.cs";
[Fact]
public void Program_UsesForwardedHeadersBeforeHttpsRedirection()
{
string source = ReadRepoFile(ProgramFile);
int forwarded = source.IndexOf("app.UseForwardedHeaders(", StringComparison.Ordinal);
int https = source.IndexOf("app.UseHttpsRedirection(", StringComparison.Ordinal);
Assert.True(forwarded >= 0, "UseForwardedHeaders doit être présent.");
Assert.True(https >= 0, "UseHttpsRedirection doit être présent.");
Assert.True(
forwarded < https,
"UseForwardedHeaders doit s'exécuter AVANT UseHttpsRedirection.");
}
[Fact]
public void ReverseProxy_DoesNotClearProxyTrustLists()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
Assert.DoesNotContain("KnownNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownIPNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownProxies.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("RequireHeaderSymmetry = false", combined, StringComparison.Ordinal);
}
[Fact]
public void ReverseProxy_DoesNotAddWildcardProxyTrust()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
// Aucune adresse « any » (0.0.0.0 / [::]) ni réseau wildcard dans la confiance.
Assert.DoesNotContain("IPAddress.Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("0.0.0.0", combined, StringComparison.Ordinal);
}
[Fact]
public void OmikaApi_DoesNotConfigureKestrelCertificate()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
// Pas de terminaison TLS dans Kestrel (certificat / HTTPS) :
// c'est Apache qui termine TLS.
Assert.DoesNotContain("UseHttps(", content, StringComparison.Ordinal);
Assert.DoesNotContain("AddHttps", content, StringComparison.Ordinal);
Assert.DoesNotContain("X509Certificate", content, StringComparison.Ordinal);
Assert.DoesNotContain(".pfx", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HttpsConnectionAdapterOptions", content, StringComparison.Ordinal);
}
}
[Fact]
public void OmikaApi_DoesNotBindToAnyAddress()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
Assert.DoesNotContain("0.0.0.0", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("ListenAnyIP", content, StringComparison.Ordinal);
}
}
[Fact]
public void Program_DoesNotHardcodeKestrelPort()
{
string source = ReadRepoFile(ProgramFile);
// Le port (5080) et l'URL doivent rester dans appsettings.Production.json,
// jamais en dur dans Program.cs.
Assert.DoesNotContain("5080", source, StringComparison.Ordinal);
Assert.DoesNotContain("UseUrls", source, StringComparison.Ordinal);
}
private static string ReadRepoFile(string relativePath)
{
return File.ReadAllText(Path.Combine(RepoRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)));
}
private static IEnumerable<string> EnumerateOmikaApiSourceFiles()
{
string apiRoot = Path.Combine(RepoRoot(), "src", "Omika.Api");
return Directory.EnumerateFiles(apiRoot, "*.cs", SearchOption.AllDirectories)
.Where(f =>
!f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) &&
!f.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar));
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
+200
View File
@@ -0,0 +1,200 @@
using System.Net;
using System.Net.Http.Json;
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.Mobile.Sync;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de synchronisation mobile.
/// Vérifie l'authentification (sans token) et la réception d'une opération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class SyncEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeSyncOperationService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public SyncEndpointsTests()
{
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",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ISyncOperationService>();
services.AddScoped<ISyncOperationService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Receive_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Receive_ValidOperation_Returns200Received()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<SyncOperationReceipt>();
Assert.NotNull(body);
Assert.Equal("RECEIVED", body.Status);
}
[Fact]
public async Task Receive_ValidOperation_PassesIdentityFromToken()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(_fakeService.LastIdentity);
Assert.Equal("BATIK.01", _fakeService.LastIdentity.CodeClient);
Assert.Equal("device-abc-123", _fakeService.LastIdentity.DeviceId);
Assert.Equal("user-1", _fakeService.LastIdentity.UserId);
}
private static SyncOperation ValidOperation()
{
return new SyncOperation
{
OperationId = Guid.NewGuid(),
DeviceId = "device-abc-123",
EntityType = "Client",
OperationType = SyncOperationType.Create,
EntityGuid = Guid.NewGuid(),
Payload = "{}"
};
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeSyncOperationService : ISyncOperationService
{
public SyncOperationReceipt Receipt { get; set; } = new();
public MobileTokenIdentity? LastIdentity { get; private set; }
public Task<SyncOperationReceipt> ReceiveAsync(
SyncOperation operation,
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
LastIdentity = identity;
return Task.FromResult(Receipt);
}
public Task<SyncStatusResponse> GetStatusAsync(
Guid operationId,
string authenticatedDeviceId,
CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncStatusResponse
{
OperationId = operationId,
Status = SyncStatus.StatusNotAvailable,
DeviceId = authenticatedDeviceId
});
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
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;
}
}
@@ -0,0 +1,270 @@
using Omika.Core.Compatibility;
using Omika.Core.Runtime;
namespace Omika.Tests;
[Collection("ApplicationRuntimeContext")]
public class ApplicationRuntimeContextTests
{
[Fact]
public void Parse_Auto_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Auto");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Auto, result.Value);
}
[Fact]
public void Parse_Batik_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Batik");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Batik, result.Value);
}
[Fact]
public void Parse_Omika_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Omika");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Omika, result.Value);
}
[Theory]
[InlineData("auto")]
[InlineData("AUTO")]
[InlineData("batik")]
[InlineData("Batik")]
[InlineData("BATIK")]
[InlineData(" Omika ")]
[InlineData("omika")]
[InlineData("OMIKA")]
public void Parse_IsCaseAndWhitespaceInsensitive(string value)
{
var result = DatabaseSchemaSelectionParser.Parse(value);
Assert.True(result.IsSuccess);
}
[Fact]
public void Parse_UnknownValue_ReturnsClearError()
{
var result = DatabaseSchemaSelectionParser.Parse("2024");
Assert.True(result.IsFailure);
var error = Assert.Single(result.Errors);
Assert.Contains("2024", error, StringComparison.OrdinalIgnoreCase);
Assert.Contains(nameof(DatabaseSchemaSelection.Auto), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaSelection.Batik), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaSelection.Omika), error, StringComparison.Ordinal);
}
[Fact]
public void Parse_NullOrEmpty_ReturnsClearError()
{
var result = DatabaseSchemaSelectionParser.Parse(null);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void TryParse_KnownValue_ReturnsTrue()
{
var ok = DatabaseSchemaSelectionParser.TryParse("Batik", out var selection);
Assert.True(ok);
Assert.Equal(DatabaseSchemaSelection.Batik, selection);
}
[Fact]
public void TryParse_UnknownValue_ReturnsFalse()
{
var ok = DatabaseSchemaSelectionParser.TryParse("inconnu", out _);
Assert.False(ok);
}
[Fact]
public void Configure_UnknownValue_ThrowsClearError()
{
ApplicationRuntimeContext.Reset();
var ex = Assert.Throws<InvalidOperationException>(() => ApplicationRuntimeContext.Configure("inconnu"));
Assert.Contains("inconnu", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ApplicationRuntimeContext.ConfigurationKey, ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Configure_ForcedSelection_WithoutDetection_IsNotActive()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
Assert.True(ApplicationRuntimeContext.IsConfigured);
Assert.Equal(DatabaseSchemaSelection.Omika, ApplicationRuntimeContext.Current.Selection);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void Configure_Auto_WithoutDetection_IsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
Assert.True(ApplicationRuntimeContext.IsConfigured);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
}
[Fact]
public void Configure_Auto_WithDetectedOmika_IsOmika()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
Assert.Equal(DatabaseSchemaVersion.Omika, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_Auto_WithDetectedBatik_IsBatik()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsBatik);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.Equal(DatabaseSchemaVersion.Batik, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedOmika_WithDetectedOmika_IsOmika()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsOmika);
Assert.Equal(DatabaseSchemaVersion.Omika, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedBatik_WithDetectedBatik_IsBatik()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Batik);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsBatik);
Assert.Equal(DatabaseSchemaVersion.Batik, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedOmika_WithDetectedBatik_ReturnsFailureAndKeepsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void Configure_ForcedBatik_WithDetectedOmika_ReturnsFailureAndKeepsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Batik);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void SetDetectedVersion_UnknownVersion_ReturnsFailure()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Unknown);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void ValidateDetectedVersion_ForcedSelection_Mismatch_ReturnsFailure()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.Current.ValidateDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsFailure);
var error = Assert.Single(result.Errors);
Assert.Contains(nameof(DatabaseSchemaSelection.Omika), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaVersion.Batik), error, StringComparison.Ordinal);
}
[Fact]
public void FromConfiguration_ReturnsContextWithParsedSelection()
{
var result = ApplicationRuntimeContext.FromConfiguration("Batik");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Batik, result.Value.Selection);
Assert.Equal(DatabaseSchemaVersion.Unknown, result.Value.DetectedVersion);
Assert.False(result.Value.IsBatik);
Assert.False(result.Value.IsOmika);
}
[Fact]
public void FromConfiguration_UnknownValue_ReturnsFailure()
{
var result = ApplicationRuntimeContext.FromConfiguration("n'importe quoi");
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void Current_WithoutConfiguration_ThrowsClearError()
{
ApplicationRuntimeContext.Reset();
var ex = Assert.Throws<InvalidOperationException>(() => _ = ApplicationRuntimeContext.Current);
Assert.Contains(nameof(ApplicationRuntimeContext.Configure), ex.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,530 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Models.Batik;
using Omika.Data.Repositories.Batik;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests de la stratégie BULK bornée de
/// <see cref="BatikInterventionRepository.SearchDetailsAsync"/>.
///
/// Ces tests garantissent que le bulk des interventions :
/// <list type="bullet">
/// <item>n'appelle JAMAIS GetDetailsAsync par intervention (anti-N+1) ;</item>
/// <item>charge chaque table relationnelle en UNE SEULE requête HFSQL (colonnes
/// ciblées) puis filtre en mémoire via un HashSet des identifiants de la page ;
/// le nombre de requêtes relationnelles est borné (1 par table), indépendant du
/// nombre d'interventions ;</item>
/// <item>n'utilise aucun WHERE IN massif ni aucun batching Chunk.</item>
/// </list>
///
/// Le test comportemental <see cref="SearchDetailsAsync_200Interventions_RelationsBulkBornees"/>
/// exécute réellement le repository sur un <see cref="SpyingHfsqlConnection"/>
/// (fake mémoire, aucun HFSQL réel) et vérifie le comportement observé :
/// nombre de requêtes borné (1 par table), absence de GetById individuel,
/// filtrage correct des lignes hors page, assemblage correct des relations.
/// Il ne repose sur aucune recherche de texte source.
/// </summary>
public class BatikInterventionBulkStrategyTests
{
private static Type RepositoryType => typeof(BatikInterventionRepository);
[Fact]
public void SearchDetailsAsync_DoesNotCallGetDetailsAsync()
{
// Le bulk SearchDetailsAsync doit assembler les relations en mémoire et
// ne JAMAIS déléguer à GetDetailsAsync par intervention (N+1).
MethodInfo? searchDetails = RepositoryType.GetMethod(
"SearchDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(searchDetails);
// Vérifie que le corps de SearchDetailsAsync ne contient pas d'appel à
// GetDetailsAsync. On inspecte le code IL : aucun appel à la méthode
// GetDetailsAsync ne doit être présent.
MethodBody? body = searchDetails.GetMethodBody();
Assert.NotNull(body);
byte[]? il = body.GetILAsByteArray();
Assert.NotNull(il);
byte[] ilBytes = il;
// Recherche de la signature de GetDetailsAsync dans le même type.
MethodInfo? getDetails = RepositoryType.GetMethod(
"GetDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(getDetails);
// Le token de la méthode GetDetailsAsync ne doit pas apparaître dans le
// corps IL de SearchDetailsAsync.
int token = getDetails.MetadataToken;
byte[] tokenBytes = BitConverter.GetBytes(token);
Assert.DoesNotContain(ilBytes, b => b == tokenBytes[0] && ContainsToken(ilBytes, tokenBytes));
}
[Fact]
public void BulkRelationLoaders_Exist()
{
// Les trois chargeurs relationnels BULK doivent exister.
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionPersonnesBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionCollaborateursBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadEtatsInterventionBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
}
[Fact]
public void BulkRelationLoaders_UseTargetedColumnSelection()
{
// Chaque chargeur relationnel BULK doit lire des colonnes ciblées (et
// non SELECT *) : les constantes SQL des colonnes doivent exister.
// Combiné au test comportemental SearchDetailsAsync_200Interventions_RelationsBulkBornees,
// cela garantit une lecture relationnelle ciblée en une seule requête,
// jamais un full scan de table entière ni un SELECT *.
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionPersonneColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionCollaborateurColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectEtatInterventionColumns",
BindingFlags.NonPublic | BindingFlags.Static));
}
[Fact]
public async Task SearchDetailsAsync_200Interventions_RelationsBulkBornees()
{
// Test COMPORTEMENTAL : exécute réellement SearchDetailsAsync sur un
// fake mémoire (SpyingHfsqlConnection) qui enregistre chaque requête.
// Aucun HFSQL réel, aucun ODBC, aucun réseau.
//
// Scénario : 200 interventions, chacune avec 1 personne et 1 état. Les
// tables relationnelles lues dans ce chemin (Intervention_Personne,
// EtatIntervention) doivent être chargées en UNE SEULE requête (lecture
// bulk ciblée), puis filtrées en mémoire via un HashSet. Le nombre de
// requêtes relationnelles doit être borné à 1 par table, soit 2 requêtes
// relationnelles au total — jamais 200 par table (N+1), jamais 4 par
// table (batching Chunk).
//
// On ajoute aussi des lignes PARASITES (interventions hors page, IDs
// 1000+) dans chaque table relationnelle pour vérifier que le filtre
// HashSet les exclut correctement : la lecture globale ne doit pas
// mélanger les relations d'autres interventions.
const int interventionCount = 200;
const int parasiteCount = 5;
var connection = new SpyingHfsqlConnection();
connection.InterventionRows = BuildInterventionRows(interventionCount);
connection.PersonneRows = BuildPersonneRows(interventionCount, parasiteCount);
connection.CollaborateurRows = BuildCollaborateurRows(interventionCount, parasiteCount);
connection.EtatRows = BuildEtatRows(interventionCount, parasiteCount);
var repository = new BatikInterventionRepository(connection);
PagedResult<Intervention> result = await repository.SearchDetailsAsync(
new InterventionSearchCriteria { Page = 1, PageSize = interventionCount });
// 1. Toutes les interventions sont retournées.
Assert.Equal(interventionCount, result.Items.Count);
// 2. Nombre de requêtes relationnelles borné : EXACTEMENT 1 par table.
// Aucune requête par intervention (N+1) : sinon on aurait 200 requêtes
// par table. Aucun batching Chunk : sinon on aurait 4 requêtes par
// table.
int personneQueries = connection.CountQueriesContaining("FROM Intervention_Personne");
int collaborateurQueries = connection.CountQueriesContaining("FROM Intervention_Collaborateur");
int etatQueries = connection.CountQueriesContaining("FROM EtatIntervention");
Assert.Equal(1, personneQueries);
// SearchDetailsAsync charge en bulk les personnes et les états. La table
// Intervention_Collaborateur n'est pas lue dans ce chemin : les
// collaborateurs sont résolus par la couche service (MobileIdentityMapper)
// à partir de la référence source, pas par un bulk relationnel ici.
Assert.Equal(0, collaborateurQueries);
Assert.Equal(1, etatQueries);
// 3. Aucun GetByIdAsync individuel : aucune requête ne cible une seule
// intervention par IN_PKID = ? (pattern du détail).
Assert.Equal(0, connection.CountQueriesContaining("WHERE IN_PKID = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IP_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IC_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE EI_IDIntervention = ?"));
// 4. Aucun WHERE IN massif dans les lectures relationnelles : les tables
// relationnelles sont lues globalement (sans clause IN) puis filtrées
// en mémoire via un HashSet. L'enrichissement client/site/adresse
// utilise une jointure unique avec un IN paramétré borné à la page,
// ce qui n'est pas une lecture relationnelle.
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Personne", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM EtatIntervention", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Collaborateur", "IN ("));
// 5. Les relations sont assemblées correctement : chaque intervention
// porte exactement 1 action (état) rattachée. Les lignes parasites
// (interventions hors page) ne doivent pas être rattachées.
foreach (Intervention intervention in result.Items)
{
Assert.Single(intervention.Actions);
}
}
// --- Helpers --------------------------------------------------------------
private static List<Dictionary<string, object?>> BuildInterventionRows(int count)
{
var rows = new List<Dictionary<string, object?>>(count);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IN_PKID"] = (long)i,
["IN_IDTypeIntervention"] = 0L,
["IN_IDEtatIntervention"] = 0L,
["IN_IDSuiviPar"] = 0L,
["IN_IDDossier"] = 0L,
["IN_IDDemandeur"] = 0L,
["IN_IDClient"] = 0L,
["IN_IDLieu"] = 0L,
["IN_Description"] = null,
["IN_AttestationTVAPresente"] = false,
["IN_NoteInterne"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L,
["IN_Titre"] = $"Intervention {i}",
["IN_RefInterne"] = $"INT-{i:D4}",
["IN_RefExterne"] = null,
["IN_IDImport"] = null,
["IN_IDSociete"] = 0L,
["Archive"] = false,
["DateArchivage"] = DateTime.MinValue,
["IN_DateIntervention"] = new DateOnly(2026, 1, 1),
["IN_DureePrevue"] = 0f,
["IN_NumDevis"] = null,
["IN_DateDevis"] = default(DateOnly),
["IN_NumFacture"] = null,
["IN_DateFacture"] = default(DateOnly),
["IN_NumIntervention"] = null
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildPersonneRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = (long)i,
["IP_IDIntervention"] = (long)i,
["IP_IDPersonne"] = (long)i,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+). Elles doivent
// être exclues par le filtre HashSet du repository.
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = id,
["IP_IDIntervention"] = id,
["IP_IDPersonne"] = id,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildCollaborateurRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = (long)i,
["IC_IDIntervention"] = (long)i,
["IC_IDCollaborateur"] = (long)i,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = id,
["IC_IDIntervention"] = id,
["IC_IDCollaborateur"] = id,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildEtatRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = (long)i,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État {i}",
["EI_IDIntervention"] = (long)i
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = id,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État parasite {p}",
["EI_IDIntervention"] = id
});
}
return rows;
}
private static bool ContainsToken(byte[] il, byte[] tokenBytes)
{
for (int i = 0; i <= il.Length - tokenBytes.Length; i++)
{
bool match = true;
for (int j = 0; j < tokenBytes.Length; j++)
{
if (il[i + j] != tokenBytes[j])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
// =========================================================================
// SpyingHfsqlConnection
// =========================================================================
// Fake mémoire de IHfsqlConnection qui enregistre chaque requête SQL et
// retourne des lignes par table. Aucun HFSQL réel, aucun ODBC, aucun réseau.
// Permet de compter les requêtes et de vérifier le comportement bulk réel.
// =========================================================================
private sealed class SpyingHfsqlConnection : IHfsqlConnection
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
public int QueryCount { get; private set; }
public IReadOnlyList<string> AllSql => _allSql;
private readonly List<string> _allSql = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> InterventionRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PersonneRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> EtatRows { get; set; } = [];
public int CountQueriesContaining(params string[] fragments)
=> _allSql.Count(sql => fragments.All(f => sql.Contains(f, StringComparison.OrdinalIgnoreCase)));
private void Record(string sql)
{
QueryCount++;
_allSql.Add(sql);
}
public Task OpenAsync(CancellationToken cancellationToken = default)
{
State = HfsqlConnectionState.Open;
return Task.CompletedTask;
}
public Task CloseAsync()
{
State = HfsqlConnectionState.Closed;
return Task.CompletedTask;
}
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
if (sql.Contains("COUNT(*)", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<object?>((long)InterventionRows.Count);
}
return Task.FromResult<object?>(null);
}
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(0);
}
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(ResolveRows(sql, parameters));
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
string tableName, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
}
public ValueTask DisposeAsync()
{
State = HfsqlConnectionState.Closed;
return ValueTask.CompletedTask;
}
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
string sql, IReadOnlyList<object?>? parameters)
{
// Les requêtes relationnelles sont des lectures globales (SELECT ...
// FROM table, sans WHERE IN) : le repository filtre ensuite en mémoire
// via un HashSet des identifiants de la page. Le fake retourne donc
// TOUTES les lignes (y compris les parasites hors page) pour que le
// filtre mémoire du repository soit réellement exercé.
if (ContainsFrom(sql, "Intervention_Personne"))
{
return FilterByInterventionId(PersonneRows, parameters, "IP_IDIntervention");
}
if (ContainsFrom(sql, "Intervention_Collaborateur"))
{
return FilterByInterventionId(CollaborateurRows, parameters, "IC_IDIntervention");
}
if (ContainsFrom(sql, "EtatIntervention"))
{
return FilterByInterventionId(EtatRows, parameters, "EI_IDIntervention");
}
if (ContainsFrom(sql, "Intervention"))
{
return InterventionRows;
}
return [];
}
private static IReadOnlyList<IReadOnlyDictionary<string, object?>> FilterByInterventionId(
IReadOnlyList<IReadOnlyDictionary<string, object?>> rows,
IReadOnlyList<object?>? parameters,
string idColumn)
{
if (parameters is null || parameters.Count == 0)
{
return rows;
}
var ids = new HashSet<long>();
foreach (object? p in parameters)
{
if (p is long l)
{
ids.Add(l);
}
else if (p is int i)
{
ids.Add(i);
}
}
if (ids.Count == 0)
{
return rows;
}
return rows
.Where(r => r.TryGetValue(idColumn, out object? v) && v is long id && ids.Contains(id))
.ToList();
}
private static bool ContainsFrom(string sql, string table)
{
string marker = $"FROM {table}";
int index = sql.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
int next = index + marker.Length;
if (next >= sql.Length || (!char.IsLetterOrDigit(sql[next]) && sql[next] != '_'))
{
return true;
}
index = sql.IndexOf(marker, index + 1, StringComparison.OrdinalIgnoreCase);
}
return false;
}
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
using Omika.Core.Observability;
namespace Omika.Tests.Architecture;
/// <summary>
/// Règle d'architecture (Phase 1) : le métier ne doit référencer aucune pile
/// d'export d'observabilité (OpenTelemetry, SigNoz, ClickHouse, OTLP). Il ne
/// connaît que les abstractions <c>IOmikaTelemetry</c> / <c>IEventTracker</c> /
/// <c>IDiagnosticLogger</c>. OpenTelemetry n'étant pas installé, ce test prépare
/// la règle pour les phases suivantes.
/// </summary>
public class BusinessMustNotReferenceObservabilityExportTests
{
[Fact]
public void OmikaCore_DoesNotReference_OpenTelemetryOrSigNozOrClickHouseOrOtlp()
{
Assembly core = typeof(IOmikaTelemetry).Assembly;
string[] referenced = core.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(referenced, n => n.StartsWith("OpenTelemetry", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("SigNoz", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("ClickHouse", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.Contains("OTLP", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,21 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer ASP.NET Core : les contrats sont de purs
/// DTO (pagination incluse) sans dépendance framework web.
/// </summary>
public class CoreMustNotReferenceAspNetCoreTests
{
[Fact]
public void OmikaCore_DoesNotReference_AspNetCore()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.Extensions.Primitives", StringComparison.Ordinal));
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Avalonia : le domaine et les contrats sont
/// des bibliothèques de classes pures.
/// </summary>
public class CoreMustNotReferenceAvaloniaTests
{
[Fact]
public void OmikaCore_DoesNotReference_Avalonia()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] avaloniaNames =
[
"Avalonia",
"Avalonia.Base",
"Avalonia.Controls",
"Avalonia.Desktop",
"Avalonia.Themes.Fluent"
];
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => avaloniaNames.Contains(name));
}
}
@@ -0,0 +1,45 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Omika.Data : le modèle métier canonique et
/// les contrats API sont indépendants de la persistance HFSQL.
/// </summary>
public class CoreMustNotReferenceDataTests
{
[Fact]
public void OmikaCore_DoesNotReference_OmikaData()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
Assert.DoesNotContain(
core.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void OmikaCore_ContainsNoHfSqlAttribute()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
// Les attributs HFSQL appartiennent à Omika.Data : aucun type d'Omika.Core
// ne doit en porter.
var hfSqlAttributeNames = new[]
{
"HfSqlTableAttribute",
"HfSqlColumnAttribute",
"HfSqlPrimaryKeyAttribute"
};
foreach (Type type in core.GetTypes())
{
foreach (string attributeName in hfSqlAttributeNames)
{
Assert.DoesNotContain(
type.GetCustomAttributesData(),
attr => attr.AttributeType.Name == attributeName);
}
}
}
}
@@ -0,0 +1,165 @@
using System.Reflection;
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Interfaces;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests du contrat canonique <see cref="IClientRepository"/> : indépendance
/// vis-à-vis d'Omika.Data, de HFSQL/ODBC et des modèles physiques, périmètre
/// lecture seule et compatibilité avec une implémentation en mémoire.
/// </summary>
public class IClientRepositoryContractTests
{
private static Type RepositoryType => typeof(IClientRepository);
private static Assembly CoreAssembly => typeof(IClientRepository).Assembly;
[Fact]
public void Interface_ExistsInOmikaCore()
{
Assert.True(typeof(IClientRepository).IsInterface);
Assert.Equal("Omika.Core", CoreAssembly.GetName().Name);
}
[Fact]
public void Core_DoesNotReferenceOmikaData()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void Core_DoesNotReferenceOdbcOrHfSql()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "System.Data.Odbc");
}
[Fact]
public void Signatures_DoNotExposePhysicalRowTypes()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.False(
method.ReturnType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le retour de « {method.Name} » expose un type physique « {method.ReturnType.Name} ».");
foreach (ParameterInfo parameter in method.GetParameters())
{
Assert.False(
parameter.ParameterType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le paramètre « {parameter.Name} » de « {method.Name} » expose un type physique « {parameter.ParameterType.Name} ».");
}
}
}
[Fact]
public void HasSearchMethod_ReturningCanonicalPagedResult()
{
MethodInfo? search = RepositoryType.GetMethod("SearchAsync");
Assert.NotNull(search);
Assert.Equal(typeof(Task<PagedResult<Client>>), search.ReturnType);
}
[Fact]
public void HasDetailsMethod_ReturningNullableClient()
{
MethodInfo? details = RepositoryType.GetMethod("GetDetailsAsync");
Assert.NotNull(details);
Assert.Equal(typeof(Task<Client>), details.ReturnType);
}
[Fact]
public void AllMethods_AcceptCancellationToken()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.Contains(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(CancellationToken));
}
}
[Fact]
public void HasNoDeleteMethods()
{
// CreateAsync, UpdateAsync et ArchiveAsync existent maintenant (écriture
// supportée). Seule la suppression physique (Delete) reste interdite.
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Delete", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void HasNoBatikOrOmikaSpecificMethods()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Batik", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Omika", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Legacy", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void CanBeImplementedByInMemoryFake()
{
var fake = new InMemoryClientRepository();
PagedResult<Client> result = fake.SearchAsync(new ClientSearchCriteria()).GetAwaiter().GetResult();
Client? missing = fake.GetDetailsAsync("CL-001").GetAwaiter().GetResult();
Assert.Empty(result.Items);
Assert.Null(missing);
}
/// <summary>
/// Implémentation en mémoire minimale prouvant que le contrat est
/// implémentable sans aucune dépendance physique.
/// </summary>
private sealed class InMemoryClientRepository : IClientRepository
{
public Task<Client> CreateAsync(
ClientCreateCommand command,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(command);
return Task.FromResult(new Client { Nom = command.Nom });
}
public Task<PagedResult<Client>> SearchAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<PagedResult<Client>> SearchDetailsAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<Client?> GetDetailsAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<Client?> UpdateAsync(
string id, Omika.Core.Domain.Commands.ClientUpdateCommand command,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<bool> ArchiveAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit contenir de nom de rubrique HFSQL, de préfixe
/// physique (CL_, IN_, PL_, RDV_, DC_…) ni de nom de table de base.
/// </summary>
public class PublicContractsMustNotContainHfSqlNamesTests
{
private static readonly string[] ForbiddenFragments =
[
"CL_", "IN_", "PL_", "RDV_", "AD_Ligne", "DC_",
"PKID", "IDType", "CleType", "IDStatut", "CleStatut",
"RDP_GUID", "PL_PKID", "RDP_", "HfSql", "HFSQL"
];
private static readonly string[] ForbiddenTableNames =
[
"Planning_RDV", "RDV_Planning", "RDV_Personne", "RDV_Collaborateur",
"RDV_Contact", "AccesEtDroit", "CategorieAcces", "RubriquesDocument"
];
/// <summary>
/// Noms interdits en tant que nom de propriété complet (et non sous-chaîne,
/// pour ne pas rejeter des mots métier français comme « Comptable »).
/// </summary>
private static readonly string[] ForbiddenExactPropertyNames =
[
"Table", "Row"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true)
.Where(t => !t.IsGenericTypeDefinition || t.Name.Contains("PagedResult", StringComparison.Ordinal));
private static IEnumerable<string> GetPublicPropertyNames(Type type)
=> type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name);
[Fact]
public void NoContractType_HasPhysicalTableOrPrefixInItsName()
{
foreach (Type type in GetContractTypes())
{
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
type.Name.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le type de contrat « {type.FullName} » contient la référence physique « {forbidden} ».");
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalPrefixOrRubricName()
{
foreach (Type type in GetContractTypes())
{
foreach (string propertyName in GetPublicPropertyNames(type))
{
// Vérification des fragments de rubrique / noms de table.
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
propertyName.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{propertyName} » contient la référence physique « {forbidden} ».");
}
// Vérification des noms interdits uniquement en nom complet,
// afin d'éviter les faux positifs (« Comptable » contient « Table »).
Assert.DoesNotContain(
ForbiddenExactPropertyNames,
name => string.Equals(name, propertyName, StringComparison.OrdinalIgnoreCase));
}
}
}
[Fact]
public void Contracts_DoNotExposeOmikaDataTypes()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string propertyTypeName = property.PropertyType.Name;
Assert.False(
propertyTypeName.Contains("Row", StringComparison.OrdinalIgnoreCase)
&& propertyTypeName.EndsWith("Row", StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose un type physique « {propertyTypeName} ».");
}
}
}
}
@@ -0,0 +1,61 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit exposer de « champ zombie » : les rubriques
/// physiques obsolètes (anciens identifiants de civilité ou de statut, clés
/// internes, rubriques marquées legacy) sont converties par le mapper et ne
/// doivent jamais transiter telles quelles vers l'application.
/// </summary>
public class PublicContractsMustNotContainZombieFieldsTests
{
private static readonly string[] ZombieMarkers =
[
"Zombie", "LegacyField", "IsLegacy", "Ancien", "OldId",
"IdCivilite", "IdStatut", "CleStatut", "CleType", "IDType",
"PKID", "RDP_GUID", "PL_PKID", "GUIDPhysique", "PhysicalGuid",
"NumeroCle", "CleNumero"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true);
[Fact]
public void NoContractProperty_HasZombieMarker()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
foreach (string marker in ZombieMarkers)
{
Assert.False(
property.Name.Contains(marker, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose le champ zombie « {marker} ».");
}
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalRowShapedTypes()
{
// Les DTO ne doivent pas exposer de propriété dont le type est une classe
// physique (Row) d'Omika.Data : les contrats sont autonomes.
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string ns = property.PropertyType.Namespace ?? string.Empty;
Assert.False(
ns.StartsWith("Omika.Data", StringComparison.Ordinal),
$"Le contrat « {type.FullName}.{property.Name} » référence un type d'Omika.Data.");
}
}
}
}
@@ -0,0 +1,61 @@
using Omika.Core.Compatibility;
namespace Omika.Tests.Compatibility;
/// <summary>
/// Tests de <see cref="LegacyEntityReference"/> : conservation de la provenance
/// (Batik / Omika) sans exposer les noms HFSQL, et identifiant public stable.
/// </summary>
public class LegacyEntityReferenceTests
{
[Fact]
public void FromLegacyId_IsBatikAndKeepsNumericId()
{
var reference = LegacyEntityReference.FromLegacyId(42);
Assert.Equal(DatabaseSchemaVersion.Batik, reference.Source);
Assert.Equal(42, reference.NumericId);
Assert.Equal("42", reference.PublicIdentifier);
}
[Fact]
public void FromGuid_KeepsEntityGuidAndSchemaVersion()
{
var guid = Guid.NewGuid();
var reference = LegacyEntityReference.FromGuid(DatabaseSchemaVersion.Omika, guid);
Assert.Equal(DatabaseSchemaVersion.Omika, reference.Source);
Assert.Equal(guid, reference.EntityGuid);
Assert.Equal(guid.ToString("D"), reference.PublicIdentifier);
}
[Fact]
public void FromBusinessKey_UsesBusinessKey()
{
var reference = LegacyEntityReference.FromBusinessKey("BK-2026-01");
Assert.Equal("BK-2026-01", reference.BusinessKey);
Assert.Equal("BK-2026-01", reference.PublicIdentifier);
}
[Fact]
public void PhysicalGuid_IsNotInterpretedAsGuid()
{
var reference = new LegacyEntityReference(DatabaseSchemaVersion.Batik, PhysicalGuid: "1A2B-étrange");
Assert.Equal(DatabaseSchemaVersion.Batik, reference.Source);
Assert.Null(reference.EntityGuid);
Assert.Equal("1A2B-étrange", reference.PhysicalGuid);
}
[Fact]
public void Reference_DoesNotCarryConnectionSecret()
{
var reference = LegacyEntityReference.FromLegacyId(7);
var publicProperties = reference.GetType().GetProperties();
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("Password", StringComparison.Ordinal));
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("ConnectionString", StringComparison.Ordinal));
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("MotDePasse", StringComparison.Ordinal));
}
}
@@ -0,0 +1,47 @@
using Omika.Core.Compatibility;
namespace Omika.Tests.Compatibility;
/// <summary>
/// Tests de <see cref="SchemaCapabilities"/> : le code métier peut savoir si une
/// fonctionnalité est disponible selon la version de base, sans connaître la
/// structure physique.
/// </summary>
public class SchemaCapabilitiesTests
{
[Fact]
public void Batik_DoesNotSupportNewAppointmentModel()
{
SchemaCapabilities batik = SchemaCapabilities.ForBatik();
Assert.Equal(DatabaseSchemaVersion.Batik, batik.Version);
Assert.False(batik.SupportsNewAppointmentModel);
Assert.False(batik.SupportsBusinessKeys);
Assert.False(batik.SupportsRoles);
}
[Fact]
public void Omika_SupportsNewFeatures()
{
SchemaCapabilities omika = SchemaCapabilities.ForOmika();
Assert.Equal(DatabaseSchemaVersion.Omika, omika.Version);
Assert.True(omika.SupportsNewAppointmentModel);
Assert.True(omika.SupportsBusinessKeys);
Assert.True(omika.SupportsEntityGuids);
Assert.True(omika.SupportsRoles);
Assert.True(omika.SupportsModificationTracking);
Assert.True(omika.SupportsReportModels);
Assert.True(omika.SupportsDocumentFields);
}
[Fact]
public void Capabilities_AreConfigurablePerInstance()
{
var custom = SchemaCapabilities.ForBatik() with { SupportsRoles = true };
Assert.True(custom.SupportsRoles);
// L'instance de base n'est pas mutée (record immutable).
Assert.False(SchemaCapabilities.ForBatik().SupportsRoles);
}
}
@@ -0,0 +1,98 @@
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class E2EOptionsTests
{
[Fact]
public void AllowHfSqlWrites_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.AllowHfSqlWrites);
}
[Fact]
public void ServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.ServiceDatabaseConfirmedTest);
}
[Fact]
public void WebServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.WebServiceDatabaseConfirmedTest);
}
[Fact]
public void IsWriteAllowed_AllFlagsFalse_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyAllowWrites_IsFalse()
{
var options = new E2EOptions { AllowHfSqlWrites = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyServiceConfirmed_IsFalse()
{
var options = new E2EOptions { ServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyWebServiceConfirmed_IsFalse()
{
var options = new E2EOptions { WebServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_AllFlagsTrue_IsTrue()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
Assert.True(options.IsWriteAllowed);
}
[Fact]
public void SectionName_IsE2E()
{
Assert.Equal("E2E", E2EOptions.SectionName);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
var text = options.ToString();
Assert.Contains("AllowHfSqlWrites = True", text);
Assert.Contains("ServiceDatabaseConfirmedTest = True", text);
Assert.Contains("WebServiceDatabaseConfirmedTest = True", text);
}
}
@@ -0,0 +1,452 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlFileAccessResolverTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = "PLUS_PASSWORD"
}
};
[Fact]
public async Task ResolveAsync_DirectPass_ReturnsDirect()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPass_ReturnsProtectedMode()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionStd, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPlusPass_ReturnsProtectionPlus()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionPlus, result["ParametreSysteme"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_NoExpectedGroup_DirectFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = null }
};
var result = await resolver.ResolveAsync(files);
// Sans groupe attendu, pas de tentative protégée : le fichier reste Failed.
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_ReturnsCachedProfile()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
var profile = resolver.GetProfile("Client");
Assert.NotNull(profile);
Assert.Equal(HfSqlFileAccessMode.Direct, profile!.ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_UnknownFile_ReturnsNull()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.Null(resolver.GetProfile("Inexistant"));
}
[Fact]
public async Task ResolveAsync_InvalidIdentifier_ReturnsFailed()
{
var factory = new FakeFactory(directPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Bad;Name", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Bad;Name"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_ExplicitStdPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionStd
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=Client:STD_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionStd", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_ExplicitPlusPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionPlus
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=ParametreSysteme:PLUS_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionPlus", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_NoPasswordsConfigured_DirectOnly()
{
// Sans passwords configurés, le resolver ne tente aucune connexion protégée.
var options = new HfSqlConnectionOptions
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions() // aucun password
};
var factory = new CapturingFactory(directPass: ["Client"], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
// Direct PASS : pas de connexion protégée nécessaire.
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
Assert.Null(factory.LastExtendedProperties);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = CreateOptions();
var str = options.ToString();
Assert.DoesNotContain("STD_PASSWORD", str);
Assert.DoesNotContain("PLUS_PASSWORD", str);
Assert.DoesNotContain("secret", str);
}
/// <summary>
/// Fabrique simulée qui capture les Extended Properties pour vérifier que
/// les mots de passe exacts des options sont utilisés (aucune dérivation).
/// </summary>
private sealed class CapturingFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public string? LastExtendedProperties { get; private set; }
public CapturingFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
if (!string.IsNullOrWhiteSpace(extendedProperties))
{
LastExtendedProperties = extendedProperties;
}
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
/// <summary>
/// Fabrique simulée : contrôle quels fichiers passent en Direct et lesquels
/// passent en protégé. Vérifie aussi que les mots de passe sont bien fournis
/// dans les Extended Properties de la connexion protégée.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public FakeFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly HashSet<string> _passingTables;
private readonly string? _extendedProperties;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(HashSet<string> passingTables, string? extendedProperties)
{
_passingTables = passingTables;
_extendedProperties = extendedProperties;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "Omika";
public override string DataSource => "hfsql-serveur:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => _state = ConnectionState.Open;
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand(_passingTables);
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
private readonly HashSet<string> _passingTables;
public FakeCommand(HashSet<string> passingTables)
{
_passingTables = passingTables;
}
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => null;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
// Extraire le nom de table depuis "SELECT TOP 1 * FROM <Table>".
var table = CommandText.Split("FROM ", StringSplitOptions.RemoveEmptyEntries)[^1].Trim();
if (_passingTables.Contains(table))
{
return new FakeReader();
}
throw new InvalidOperationException("Code erreur : 70114");
}
}
private sealed class FakeReader : DbDataReader
{
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => throw new NotSupportedException();
public override object this[string name] => throw new NotSupportedException();
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => throw new NotSupportedException();
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => throw new NotSupportedException();
public override int GetOrdinal(string name) => throw new NotSupportedException();
public override string GetString(int ordinal) => throw new NotSupportedException();
public override object GetValue(int ordinal) => throw new NotSupportedException();
public override int GetValues(object[] values) => throw new NotSupportedException();
public override bool IsDBNull(int ordinal) => throw new NotSupportedException();
public override bool NextResult() => false;
public override bool Read() => true;
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,312 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Collaborateur :
/// CREATE → UPDATE → ARCHIVE (désactivation), le tout dans UNE transaction
/// externe unique, puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbCollaborateurLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Collaborateur, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Batik.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK",
Prenom = "Cycle",
Code = "ICB",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.NumericId);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.NumericId!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.NumericId!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Omika.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA",
Prenom = "Cycle",
Code = "ICO",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.EntityGuid);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,104 @@
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlOleDbConnectionStringBuilderTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret"
};
[Fact]
public void Build_ContainsProvider()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("PCSOFT.HFSQL", connectionString);
}
[Fact]
public void Build_ContainsServerAndPort()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("hfsql-serveur:4900", connectionString);
}
[Fact]
public void Build_ContainsDatabase()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("Omika", connectionString);
}
[Fact]
public void Build_ContainsUser()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("tech", connectionString);
}
[Fact]
public void Build_ContainsCredential()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("secret", connectionString);
}
[Fact]
public void Build_OmitsExtendedPropertiesWhenNull()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.DoesNotContain("Extended Properties", connectionString);
}
[Fact]
public void Build_IncludesExtendedPropertiesWhenProvided()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(
CreateOptions(),
"Password=Client:abc123");
Assert.Contains("Extended Properties", connectionString);
Assert.Contains("Password=Client:abc123", connectionString);
}
[Fact]
public void Build_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlOleDbConnectionStringBuilder.Build(null!));
}
[Fact]
public void EscapeExtendedPropertyValue_EscapesSpecialCharacters()
{
var escaped = HfSqlOleDbConnectionStringBuilder.EscapeExtendedPropertyValue("a=b;c:d\\e");
Assert.Equal("a\\=b\\;c\\:d\\\\e", escaped);
}
[Fact]
public void BuildFilePasswordEntry_DoesNotEscapeStructuralColon()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("Client", "abc123");
Assert.Equal("Password=Client:abc123", entry);
}
[Fact]
public void BuildFilePasswordEntry_EscapesFilenameAndPasswordIndividually()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("a=b", "c;d");
Assert.Equal("Password=a\\=b:c\\;d", entry);
}
}
@@ -0,0 +1,278 @@
using System.Data.Common;
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du chemin de création (CreateAsync).
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée une transaction externe, on exécute <c>CreateAsync</c> dans cette
/// transaction, on vérifie le client créé, puis on annule (rollback) pour ne
/// laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur le client
/// canonique retourné.
/// </summary>
public class HfSqlOleDbCreateIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au chemin Create, en fonction du mode d'accès observé
/// par le résolveur. Les fichiers accessibles directement n'ont pas besoin
/// d'entrée.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task CreateAsync_Batik_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Batik.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_BATIK",
Code = "INT-CREATE-BATIK",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_BATIK", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task CreateAsync_Omika_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Omika.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_OMIKA",
Code = "INT-CREATE-OMIKA",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_OMIKA", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,306 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Dossier :
/// CREATE → UPDATE → ARCHIVE, le tout dans UNE transaction externe unique,
/// puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbDossierLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Dossier, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Batik.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
ReferenceMetier = "DOS-CYCLE-BATIK"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED",
ReferenceMetier = "DOS-CYCLE-BATIK-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Omika.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
ReferenceMetier = "DOS-CYCLE-OMIKA"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED",
ReferenceMetier = "DOS-CYCLE-OMIKA-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using Omika.Core.Compatibility;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.SchemaDetection;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// modes d'accès observés.
/// </summary>
public class HfSqlOleDbIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
[Fact]
public async Task ResolveAsync_Batik_Client_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Client"].IsAccessible,
$"Client Batik doit être accessible (mode observé : {result["Client"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task ResolveAsync_Omika_Client_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Client"].IsAccessible,
$"Client Omika doit être accessible (mode observé : {result["Client"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
// --- Détection de schéma réelle (BATIK_TEST / OMIKA_TEST) -----------------
[Fact]
public async Task DetectAsync_BatikTest_ReturnsBatik()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
var detector = new DatabaseSchemaDetector(connection);
try
{
var result = await detector.DetectAsync();
Assert.True(result.IsSuccess,
$"La détection Batik a échoué : {string.Join("; ", result.Errors)}");
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value);
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task DetectAsync_OmikaTest_ReturnsOmika()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
var detector = new DatabaseSchemaDetector(connection);
try
{
var result = await detector.DetectAsync();
Assert.True(result.IsSuccess,
$"La détection Omika a échoué : {string.Join("; ", result.Errors)}");
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
// Le provider OLE DB peut lever des exceptions natives au teardown.
// On ignore les erreurs de connexion (serveur injoignable) mais on
// propage les erreurs de logique.
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,231 @@
using System.Text.Json;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels pour les tables Intervention.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// modes d'accès observés.
/// </summary>
public class HfSqlOleDbInterventionIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
[Fact]
public async Task ResolveAsync_Batik_Intervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Intervention"].IsAccessible,
$"Intervention Batik doit être accessible (mode observé : {result["Intervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task ResolveAsync_Omika_Intervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Intervention"].IsAccessible,
$"Intervention Omika doit être accessible (mode observé : {result["Intervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact]
public async Task ResolveAsync_Batik_EtatIntervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "EtatIntervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["EtatIntervention"].IsAccessible,
$"EtatIntervention Batik doit être accessible (mode observé : {result["EtatIntervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact(Skip = "ParametreMetier n'existe pas dans la base Batik de test (HFSQL 70114 : fichier non décrit) ; ce fichier n'existe qu'en schéma Omika. La cible du test est à revoir avant réactivation.")]
public async Task ResolveAsync_Batik_ParametreMetier_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["ParametreMetier"].IsAccessible,
$"ParametreMetier Batik doit être accessible (mode observé : {result["ParametreMetier"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact]
public async Task ResolveAsync_Omika_ParametreMetier_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["ParametreMetier"].IsAccessible,
$"ParametreMetier Omika doit être accessible (mode observé : {result["ParametreMetier"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,317 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Intervention :
/// CREATE → UPDATE → ARCHIVE, le tout dans UNE transaction externe unique,
/// puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbInterventionLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Intervention, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Intervention Batik.
var logicalFiles = new[]
{
"Intervention", "ParametreMetier", "Intervention_Collaborateur",
"Intervention_Personne", "EtatIntervention"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikInterventionRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new InterventionCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
Reference = "INT-CYCLE-BATIK",
Description = "Cycle de vie complet",
CollaborateurIds = ["1"],
ContactIds = ["1"]
};
Intervention created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new InterventionUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED",
Reference = "INT-CYCLE-BATIK-UPD",
Description = "Cycle de vie mis à jour",
CollaborateurIds = ["2"],
ContactIds = ["2"]
};
Intervention? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Intervention Omika.
var logicalFiles = new[]
{
"Intervention", "ParametreMetier", "Intervention_Personne", "EtatIntervention"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaInterventionRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new InterventionCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
Reference = "INT-CYCLE-OMIKA",
Description = "Cycle de vie complet",
ContactIds = ["1"]
};
Intervention created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new InterventionUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED",
Reference = "INT-CYCLE-OMIKA-UPD",
Description = "Cycle de vie mis à jour",
ContactIds = ["2"]
};
Intervention? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,332 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Planning;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie RendezVous :
/// CREATE → UPDATE → CANCEL → ARCHIVE, le tout dans UNE transaction externe
/// unique, puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Cancel puis
/// Archive dans cette même transaction, on vérifie chaque étape, puis on annule
/// (rollback) pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbRendezVousLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie RendezVous, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateCancelArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie RendezVous Batik.
var logicalFiles = new[]
{
"Planning_RDV", "Planning_RDV_Personne", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikRendezVousRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new RendezVousCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
Description = "Cycle de vie Batik",
Debut = new DateTime(2026, 2, 1, 9, 0, 0),
Fin = new DateTime(2026, 2, 1, 10, 0, 0)
};
RendezVous created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new RendezVousUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED"
};
RendezVous? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. CANCEL dans la MÊME transaction.
var annulation = new AnnulationRendezVousCommand
{
DateAnnulation = DateTime.Now,
OrigineKey = null,
MotifKey = null
};
bool cancelled = await repository.CancelAsync(
created.Id.ToString(), annulation, transaction, CancellationToken.None);
Assert.True(cancelled);
// 8. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 9. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateCancelArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie RendezVous Omika.
var logicalFiles = new[]
{
"RDV_Planning", "RDV_Collaborateur", "RDV_Contact", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaRendezVousRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new RendezVousCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
Description = "Cycle de vie Omika",
Debut = new DateTime(2026, 2, 1, 9, 0, 0),
Fin = new DateTime(2026, 2, 1, 10, 0, 0)
};
RendezVous created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new RendezVousUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED"
};
RendezVous? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. CANCEL dans la MÊME transaction.
var annulation = new AnnulationRendezVousCommand
{
DateAnnulation = DateTime.Now,
OrigineKey = null,
MotifKey = null
};
bool cancelled = await repository.CancelAsync(
created.Id.ToString(), annulation, transaction, CancellationToken.None);
Assert.True(cancelled);
// 8. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 9. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,109 @@
using Microsoft.Extensions.Configuration;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class HfSqlTestConfigurationLoaderTests
{
[Theory]
[InlineData("Development")]
[InlineData("development")]
[InlineData("Test")]
[InlineData("test")]
public void IsTestEnvironment_TestEnvironments_ReturnsTrue(string environment)
{
Assert.True(HfSqlTestConfigurationLoader.IsTestEnvironment(environment));
}
[Theory]
[InlineData("Production")]
[InlineData("Staging")]
[InlineData(null)]
[InlineData("")]
public void IsTestEnvironment_NonTestEnvironments_ReturnsFalse(string? environment)
{
Assert.False(HfSqlTestConfigurationLoader.IsTestEnvironment(environment));
}
[Fact]
public void ResolveConfigPath_ExplicitEnvironmentVariable_ReturnsFullPath()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"hfsql-test-{Guid.NewGuid():N}.json");
try
{
File.WriteAllText(tempFile, "{}");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, tempFile);
var path = HfSqlTestConfigurationLoader.ResolveConfigPath();
Assert.NotNull(path);
Assert.Equal(Path.GetFullPath(tempFile), path);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
File.Delete(tempFile);
}
}
[Fact]
public void AddLocalTestConfiguration_ProductionEnvironment_DoesNotLoad()
{
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Production");
Assert.False(loaded);
}
[Fact]
public void AddLocalTestConfiguration_DevelopmentWithExplicitPath_LoadsFile()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"hfsql-test-{Guid.NewGuid():N}.json");
try
{
File.WriteAllText(tempFile, """{ "E2E": { "AllowHfSqlWrites": false } }""");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, tempFile);
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Development");
Assert.True(loaded);
Assert.Equal("False", configuration["E2E:AllowHfSqlWrites"]);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
File.Delete(tempFile);
}
}
[Fact]
public void AddLocalTestConfiguration_DevelopmentWithoutFile_DoesNotLoad()
{
// Pointe vers un fichier inexistant pour garantir l'absence de chargement.
var missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, missingPath);
try
{
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Development");
Assert.False(loaded);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
}
}
}
@@ -0,0 +1,322 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class HfsqlConnectionTests
{
private static HfsqlOptions CreateOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika"
};
[Fact]
public void InitialState_IsClosed()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task OpenAsync_Success_SetsOpen()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Failure_SetsFaulted()
{
var factory = new FakeFactory(throwOnOpen: true);
var connection = new HfsqlConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(() => connection.OpenAsync());
Assert.Equal(HfsqlConnectionState.Faulted, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotCreateSecondConnection()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.CreatedCount);
}
[Fact]
public async Task CloseAsync_SetsClosed()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task CloseAsync_Twice_DoesNotThrow()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.CloseAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task DisposeAsync_ClosesConnection()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.DisposeAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task ExecuteScalarAsync_WhenClosed_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.ExecuteScalarAsync("SELECT 1"));
}
[Fact]
public async Task ExecuteScalarAsync_WhenOpen_ReturnsValue()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var result = await connection.ExecuteScalarAsync("SELECT 1");
Assert.Equal(42, result);
}
[Fact]
public async Task ExecuteNonQueryAsync_WhenOpen_ReturnsAffectedRows()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var result = await connection.ExecuteNonQueryAsync("UPDATE T SET X = 1");
Assert.Equal(3, result);
}
[Fact]
public async Task QueryAsync_WhenOpen_ReturnsRows()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM T");
Assert.Single(rows);
Assert.Equal("valeur", rows[0]["Col"]);
}
[Fact]
public async Task ExecuteScalarAsync_EmptySql_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await Assert.ThrowsAsync<ArgumentException>(
() => connection.ExecuteScalarAsync(" "));
}
[Fact]
public async Task GetColumnsAsync_WhenOpen_ReturnsColumns()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var columns = await connection.GetColumnsAsync("Lieu");
Assert.Equal(2, columns.Count);
Assert.Contains(columns, c => c.ColumnName == "LI_PKID");
Assert.Contains(columns, c => c.ColumnName == "LI_CleType");
}
[Fact]
public async Task GetColumnsAsync_WhenClosed_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.GetColumnsAsync("Lieu"));
}
private sealed class FakeFactory : IHfsqlConnectionFactory
{
private readonly bool _throwOnOpen;
public FakeFactory(bool throwOnOpen = false)
{
_throwOnOpen = throwOnOpen;
}
public int CreatedCount { get; private set; }
public DbConnection Create(HfsqlOptions options)
{
CreatedCount++;
return new FakeDbConnection(_throwOnOpen);
}
}
private sealed class FakeDbConnection : DbConnection
{
private readonly bool _throwOnOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeDbConnection(bool throwOnOpen)
{
_throwOnOpen = throwOnOpen;
}
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "Omika";
public override string DataSource => "fake";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
if (_throwOnOpen)
throw new InvalidOperationException("Échec contrôlé de l'ouverture.");
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeDbCommand(this);
public override DataTable GetSchema(string collectionName, string[] restrictionValues)
{
var table = new DataTable();
table.Columns.Add("COLUMN_NAME", typeof(string));
table.Rows.Add("LI_PKID");
table.Rows.Add("LI_CleType");
return table;
}
}
private sealed class FakeDbCommand : DbCommand
{
private readonly FakeDbConnection _connection;
public FakeDbCommand(FakeDbConnection connection)
{
_connection = connection;
}
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = null;
protected override DbParameterCollection DbParameterCollection { get; } = null!;
protected override DbTransaction? DbTransaction { get; set; } = null;
public override void Cancel() { }
public override int ExecuteNonQuery() => 3;
public override object? ExecuteScalar() => 42;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => throw new NotSupportedException();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
=> new FakeDbDataReader();
}
private sealed class FakeDbDataReader : DbDataReader
{
private int _readCount;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => "valeur";
public override object this[string name] => "valeur";
public override bool Read() => _readCount++ == 0;
public override bool NextResult() => false;
public override IEnumerator GetEnumerator() => throw new NotSupportedException();
public override int Depth => 0;
public override string GetName(int ordinal) => "Col";
public override int GetOrdinal(string name) => 0;
public override string GetDataTypeName(int ordinal) => "string";
public override Type GetFieldType(int ordinal) => typeof(string);
public override object GetValue(int ordinal) => "valeur";
public override int GetValues(object[] values)
{
values[0] = "valeur";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetString(int ordinal) => throw new NotSupportedException();
}
}
@@ -0,0 +1,85 @@
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class HfsqlOdbcConnectionStringBuilderTests
{
private static HfsqlOptions CreateOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika",
Login = "tech",
Password = "secret"
};
[Fact]
public void Build_ContainsDriver()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("HFSQL Client/Server", connectionString);
}
[Fact]
public void Build_ContainsServer()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("hfsql-serveur", connectionString);
}
[Fact]
public void Build_ContainsPort()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("4900", connectionString);
}
[Fact]
public void Build_ContainsDatabase()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("Omika", connectionString);
}
[Fact]
public void Build_ContainsLogin()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("tech", connectionString);
}
[Fact]
public void Build_ContainsPassword()
{
// Le mot de passe est transmis au provider via la chaîne de connexion.
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("secret", connectionString);
}
[Fact]
public void Build_OmitsLoginAndPasswordWhenAbsent()
{
var options = CreateOptions();
options.Login = null;
options.Password = null;
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(options);
Assert.DoesNotContain("UID", connectionString);
Assert.DoesNotContain("PWD", connectionString);
}
[Fact]
public void Build_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfsqlOdbcConnectionStringBuilder.Build(null!));
}
}
@@ -0,0 +1,87 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class HfsqlOptionsValidatorTests
{
private static HfsqlOptions CreateValidOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika",
ConnectionName = "ConnexionLocale"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = HfsqlOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingDriver_ReturnsFailure()
{
var options = CreateValidOptions();
options.Driver = null!;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("driver", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidTimeout_ReturnsFailure()
{
var options = CreateValidOptions();
options.ConnectionTimeoutSeconds = 0;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("délai", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new HfsqlOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<HfsqlOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<HfsqlOptions> validator = new HfsqlOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<HfsqlOptions> validator = new HfsqlOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CreateDefault_ProvidesDriver()
{
var options = HfsqlOptionsValidator.CreateDefault();
Assert.Equal(HfsqlOptions.DefaultOdbcDriver, options.Driver);
}
}
@@ -0,0 +1,114 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class LicenseConnectionOptionsValidatorTests
{
private static LicenseConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_LICENSES",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = LicenseConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new LicenseConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<LicenseConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<LicenseConnectionOptions> validator = new LicenseConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<LicenseConnectionOptions> validator = new LicenseConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,305 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class LicenseOleDbConnectionTests
{
private static LicenseConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_LICENSES",
Login = "susana",
Password = "secret",
FilePasswords = new LicenseFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForLicense_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForLicense(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BASE_LICENSES", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForLicense_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForLicense(null!));
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM Licence");
Assert.Single(rows);
Assert.Equal("LIC-001", rows[0]["NumeroLicence"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM Licence");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM Licence"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
Assert.Equal(LicenseConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BASE_LICENSES", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BASE_LICENSES";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "NumeroLicence";
public override int GetOrdinal(string name) => name == "NumeroLicence" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "LIC-001";
public override object GetValue(int ordinal) => "LIC-001";
public override int GetValues(object[] values)
{
values[0] = "LIC-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,36 @@
using System.Data;
using System.Data.Odbc;
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class OdbcConnectionFactoryTests
{
[Fact]
public void Create_ReturnsOdbcConnection()
{
var factory = new OdbcConnectionFactory();
var options = new HfsqlOptions
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika"
};
var connection = factory.Create(options);
Assert.IsType<OdbcConnection>(connection);
Assert.Equal(ConnectionState.Closed, connection.State);
Assert.Contains("hfsql-serveur", connection.ConnectionString);
}
[Fact]
public void Create_NullOptions_Throws()
{
var factory = new OdbcConnectionFactory();
Assert.Throws<ArgumentNullException>(() => factory.Create(null!));
}
}
@@ -0,0 +1,345 @@
using System.Data.OleDb;
using System.Text;
using System.Text.Json;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Sonde RUNTIME (lecture seule) : connecte à OMIKA_TEST via OLE DB HFSQL,
/// inspecte le schéma réel des tables utilisées par
/// <see cref="Omika.Data.Repositories.Omika.OmikaRapportChampRepository"/> puis
/// exécute les 8 requêtes de pré-remplissage + les validations de jointures RDV/CR.
/// N'écrit jamais dans la base. Les résultats sont écrits dans un fichier du
/// workspace (artifacts/runtime-rapportchamp.txt) pour analyse.
/// </summary>
public class OmikaRapportChampRuntimeTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string OutputPath = @"D:\Avalonia\Omika\artifacts\runtime-rapportchamp.txt";
[Fact]
public async Task Runtime_Validate_RapportChamp_Queries()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA RAPPORT CHAMP — RUNTIME PROBE (read-only) ===");
var config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
using var connection = (OleDbConnection)factory.Create(options);
try
{
connection.Open();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED: {Redact(ex.Message)}");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
// 1. Schéma réel des tables clés.
string[] keyTables =
[
"Intervention", "Dossier", "Client", "Adresse", "Lieu", "Societe",
"Collaborateur", "ParametreMetier", "RDV_Planning", "RDV_Collaborateur",
"CompteRendu", "Personne", "Intervention_Personne", "Document",
"RubriquesDocument", "MailModele", "ModeleRapport", "ModeleRapportMob"
];
foreach (string table in keyTables)
{
sb.AppendLine();
sb.AppendLine($"── COLUMNS: {table} ──");
try
{
var cols = connection.GetSchema("Columns", [null, null, table, null]);
foreach (System.Data.DataRow row in cols.Rows)
{
string col = row["COLUMN_NAME"]?.ToString() ?? "?";
string type = row["TYPE_NAME"]?.ToString() ?? "?";
sb.AppendLine($" {col,-28} {type}");
}
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
}
// 2. Découverte d'une intervention réelle.
long interventionId = 0;
sb.AppendLine();
sb.AppendLine("── DISCOVER INTERVENTION ──");
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_IDDossier, IN_IDClient FROM Intervention";
using var reader = cmd.ExecuteReader();
int shown = 0;
while (reader.Read() && shown < 10)
{
long pk = ReadInt64(reader, 0);
if (interventionId == 0) interventionId = pk;
sb.AppendLine($" IN_PKID={pk} RefInterne={Str(reader,1)} Titre={Str(reader,2)} IDDossier={Str(reader,3)} IDClient={Str(reader,4)}");
shown++;
}
sb.AppendLine($" CHOSEN interventionId = {interventionId}");
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
if (interventionId == 0)
{
sb.AppendLine("NO INTERVENTION FOUND");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
// 3. Exécution des 8 requêtes (SQL identique au repository).
var queries = new (string Title, string Sql)[]
{
("InterventionSql", InterventionSql),
("DossierSql", DossierSql),
("ClientAdressesSql", ClientAdressesSql),
("CompteRenduSql", CompteRenduSql),
("RdvSql", RdvSql),
("ContactsSiteSql", ContactsSiteSql),
("ContactsDonneursSql", ContactsDonneursSql),
("PhotosSql", PhotosSql)
};
foreach (var q in queries)
{
sb.AppendLine();
sb.AppendLine($"── {q.Title} ──");
sb.AppendLine($" SQL: {q.Sql.Replace('\n', ' ').Trim()}");
sb.AppendLine($" PARAMS: {interventionId}");
DumpQuery(connection, sb, q.Sql, interventionId);
}
// 4. Validation jointure RDV collaborateur (RVC_GUID_Colb = Collaborateur.GUID).
sb.AppendLine();
sb.AppendLine("── RDV COLLAB JOIN VALIDATION ──");
DumpQuery(connection, sb,
"SELECT RDV_Collaborateur.RVC_PKID, RDV_Collaborateur.RVC_GUID_Colb, Collaborateur.GUID, Collaborateur.CO_Nom " +
"FROM RDV_Collaborateur LEFT OUTER JOIN Collaborateur ON RDV_Collaborateur.RVC_GUID_Colb = Collaborateur.GUID", null);
// 5. Validation sémantique CR_IDRdv.
sb.AppendLine();
sb.AppendLine("── CR_IDRDV SEMANTICS ──");
DumpQuery(connection, sb,
"SELECT CompteRendu.CR_PKID, CompteRendu.CR_IDRdv, CompteRendu.CR_GUID_Rdv, CompteRendu.CR_IDIntervention, " +
"RDV_Collaborateur.RVC_PKID, RDV_Planning.GUID " +
"FROM CompteRendu LEFT OUTER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_PKID = CompteRendu.CR_IDRdv " +
"LEFT OUTER JOIN RDV_Planning ON RDV_Planning.GUID = RDV_Collaborateur.RVC_GUID_RDV", null);
// 6. Investigation du dédoublement de rôles (ContactsDonneursSql).
sb.AppendLine();
sb.AppendLine("── IP_CleRole / IP_IDRole ──");
DumpQuery(connection, sb,
"SELECT Intervention_Personne.IP_PKID, Intervention_Personne.IP_CleRole, Intervention_Personne.IP_IDRole " +
"FROM Intervention_Personne WHERE IP_IDIntervention = ?", interventionId);
sb.AppendLine();
sb.AppendLine("── ParametreMetier PA_Cle vide (rôles) ──");
DumpQuery(connection, sb,
"SELECT COUNT(*) AS CNT FROM ParametreMetier WHERE PA_Cle = ''", null);
await File.WriteAllTextAsync(OutputPath, sb.ToString());
}
private static void DumpQuery(OleDbConnection connection, StringBuilder sb, string sql, long? param)
{
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
if (param is not null)
{
cmd.Parameters.Add(new OleDbParameter { Value = param.Value });
}
using var reader = cmd.ExecuteReader();
int fieldCount = reader.FieldCount;
var colNames = new string[fieldCount];
for (int f = 0; f < fieldCount; f++) colNames[f] = reader.GetName(f);
sb.AppendLine($" COLUMNS: {string.Join(", ", colNames)}");
int rows = 0;
while (reader.Read() && rows < 8)
{
var values = new string[fieldCount];
for (int f = 0; f < fieldCount; f++)
{
values[f] = reader.IsDBNull(f) ? "<null>" : Truncate(reader.GetValue(f)?.ToString() ?? "");
}
sb.AppendLine($" ROW: {string.Join(" | ", values)}");
rows++;
}
sb.AppendLine($" ROWS (shown): {rows}");
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
}
private static long ReadInt64(OleDbDataReader reader, int i)
{
try { return reader.IsDBNull(i) ? 0 : Convert.ToInt64(reader.GetValue(i)); }
catch { return 0; }
}
private static string Str(OleDbDataReader reader, int i)
{
try { return reader.IsDBNull(i) ? "<null>" : Truncate(reader.GetValue(i)?.ToString() ?? ""); }
catch { return "<err>"; }
}
private static string Truncate(string s) => s.Length <= 60 ? s : s[..60] + "…";
private static string Redact(string msg)
{
msg = msg.Replace("Password", "[PWD]", StringComparison.OrdinalIgnoreCase);
return msg;
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath)) return null;
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch { return null; }
}
// === SQL identique à OmikaRapportChampRepository (copie fidèle) ===
private const string InterventionSql =
"SELECT Intervention.IN_Titre AS IN_Titre, Intervention.IN_RefInterne AS IN_RefInterne, " +
"Intervention.CreationDate AS IN_CreationDate, Intervention.IN_NumDevis AS IN_NumDevis, " +
"Intervention.IN_DateDevis, Intervention.IN_NumFacture, Intervention.IN_DateFacture, " +
"Intervention.IN_RefExterne AS IN_RefExterne, Intervention.IN_Description AS IN_Description, " +
"Intervention.IN_DureePrevue, " +
"CONCAT(Collaborateur.CO_Prenom, ' ', Collaborateur.CO_Nom) AS IN_CO_Nom, " +
"PA_TypeInter.PA_Libelle AS IN_PA_Type, Lieu.LI_CodeLieu AS LI_CodeLieu, " +
"CONCAT(AdLieu.AD_Adresse, ' ', AdLieu.AD_CodePostal, ' ', AdLieu.AD_Ville) AS LI_Adresse, " +
"Lieu.LI_CodeAcces AS LI_CodeAcces, Client.CL_Nom AS CL_Nom, " +
"CONCAT(AdClient.AD_Adresse, ' ', AdClient.AD_CodePostal, ' ', AdClient.AD_Ville) AS AD_Presentation, " +
"Societe.SO_Nom " +
"FROM Intervention " +
"LEFT OUTER JOIN Lieu ON Intervention.IN_IDLieu = Lieu.LI_PKID " +
"LEFT OUTER JOIN Adresse AdLieu ON Lieu.LI_IDAdresse = AdLieu.AD_PKID " +
"LEFT OUTER JOIN Dossier ON Intervention.IN_IDDossier = Dossier.DO_PKID " +
"LEFT OUTER JOIN Client ON Dossier.DO_IDClient = Client.CL_PKID " +
"LEFT OUTER JOIN Adresse AdClient ON Client.CL_IDAdressePrincipale = AdClient.AD_PKID " +
"LEFT OUTER JOIN Collaborateur ON Intervention.IN_IDSuiviPar = Collaborateur.CO_PKID " +
"LEFT OUTER JOIN Societe ON Intervention.IN_IDSociete = Societe.SO_PKID " +
"LEFT OUTER JOIN ParametreMetier PA_TypeInter ON Intervention.IN_CleType = PA_TypeInter.PA_CLE " +
"WHERE Intervention.IN_PKID = ?";
private const string DossierSql =
"SELECT Dossier.DO_Titre AS DOSSIER_TITRE, Dossier.DO_RefExterne AS DOSSIER_REFEXT, " +
"Dossier.DO_NumDossier AS DOSSIER_NUM, Dossier.DO_DateDebut AS DOSSIER_DATE, " +
"PA_StatutDossier.PA_Libelle AS DOSSIER_STATUT " +
"FROM Dossier INNER JOIN Intervention ON Intervention.IN_IDDossier = Dossier.DO_PKID " +
"LEFT OUTER JOIN ParametreMetier PA_StatutDossier ON Dossier.DO_CleStatut = PA_StatutDossier.PA_CLE " +
"WHERE Intervention.IN_PKID = ?";
private const string ClientAdressesSql =
"SELECT CONCAT(Adresse.AD_Adresse, ' ', Adresse.AD_CodePostal, ' ', Adresse.AD_Ville) AS Adresse " +
"FROM Client INNER JOIN Intervention ON Intervention.IN_IDClient = Client.CL_PKID " +
"LEFT JOIN Client_Adresse ON Client_Adresse.CA_IDClient = Client.CL_PKID " +
"INNER JOIN Adresse ON Adresse.AD_PKID = Client_Adresse.CA_IDAdresse " +
"WHERE Intervention.IN_PKID = ?";
private const string CompteRenduSql =
"SELECT CompteRendu.GUID, CompteRendu.CR_DateHeureDebut, CompteRendu.CR_DateHeureFin, " +
"CompteRendu.CR_NomSignataire, CompteRendu.CR_NomSignataireTech " +
"FROM CompteRendu " +
"INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_PKID = CompteRendu.CR_IDRdv " +
"INNER JOIN RDV_Planning ON RDV_Planning.GUID = RDV_Collaborateur.RVC_GUID_RDV " +
"WHERE RDV_Collaborateur.Archive = 0 AND RDV_Planning.Archive = 0 AND RDV_Planning.RV_IdInter = ?";
private const string RdvSql =
"SELECT RDV_Planning.GUID, RDV_Collaborateur.RVC_DateDeb, RDV_Collaborateur.RVC_DateFin, " +
"RDV_Planning.RV_Objet, RDV_Planning.RV_Description, RDV_Planning.RV_Groupe, " +
"CONCAT(Collaborateur.CO_Prenom, ' ', Collaborateur.CO_Nom) AS PL_CO_NOM " +
"FROM RDV_Planning " +
"INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_GUID_RDV = RDV_Planning.GUID " +
"LEFT OUTER JOIN Collaborateur ON RDV_Collaborateur.RVC_GUID_Colb = Collaborateur.GUID " +
"WHERE RDV_Planning.Archive = 0 AND RDV_Collaborateur.Archive = 0 AND RDV_Planning.RV_IdInter = ? " +
"ORDER BY RDV_Collaborateur.RVC_DateDeb";
private const string ContactsSiteSql =
"SELECT Personne.PE_PKID, Personne.PE_Nom, Personne.PE_Prenom, Personne.PE_TelFixe, " +
"Personne.PE_TelMobile, Intervention_Personne.IP_Etage, ParametreMetier.PA_Libelle AS IP_Role " +
"FROM Intervention_Personne " +
"INNER JOIN Intervention ON Intervention.IN_PKID = Intervention_Personne.IP_IDIntervention " +
"INNER JOIN Personne ON Personne.PE_PKID = Intervention_Personne.IP_IDPersonne " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Intervention_Personne.IP_CleRole " +
"WHERE Intervention_Personne.IP_IDIntervention = ? AND Intervention_Personne.IP_IDLieu = Intervention.IN_IDLieu " +
"ORDER BY Intervention_Personne.IP_PKID";
private const string ContactsDonneursSql =
"SELECT Personne.PE_PKID, Personne.PE_Nom, Personne.PE_Prenom, Personne.PE_TelFixe, " +
"Personne.PE_TelMobile, Intervention_Personne.IP_Etage, ParametreMetier.PA_Libelle AS IP_Role " +
"FROM Intervention_Personne " +
"INNER JOIN Intervention ON Intervention.IN_PKID = Intervention_Personne.IP_IDIntervention " +
"INNER JOIN Personne ON Personne.PE_PKID = Intervention_Personne.IP_IDPersonne " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Intervention_Personne.IP_CleRole " +
"WHERE Intervention_Personne.IP_IDIntervention = ? AND Intervention_Personne.IP_IDClient = Intervention.IN_IDClient " +
"ORDER BY Intervention_Personne.IP_PKID";
private const string PhotosSql =
"SELECT Document.DC_PKID, Document.GUID, Document.DC_Nom, Document.DC_DateFichier, Document.DC_Chemin, " +
"ParametreMetier.PA_Libelle AS CAT_Libelle " +
"FROM Document " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Document.DC_CleCategorie " +
"WHERE Document.DC_IDIntervention = ? ORDER BY Document.DC_PKID, Document.DC_DateFichier";
}
@@ -0,0 +1,150 @@
using System.Data.OleDb;
using System.Text;
using System.Text.Json;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Sonde RUNTIME (lecture seule) : résout la racine documentaire et un échantillon
/// de photos Omika pour valider le chemin physique réel. N'écrit jamais en base.
/// Résultats dans artifacts/runtime-photopath.txt.
/// </summary>
public class PhotoPathRuntimeTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string OutputPath = @"D:\Avalonia\Omika\artifacts\runtime-photopath.txt";
[Fact]
public async Task Runtime_PhotoPath_Resolve()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA PHOTO PATH — RUNTIME PROBE (read-only) ===");
var config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
using var connection = (OleDbConnection)factory.Create(options);
try
{
connection.Open();
sb.AppendLine("CONNECTION: OPEN");
// 1. Racine documentaire.
string root = Scalar(connection, "SELECT PAP_Valeur FROM ParametreApplication WHERE PAP_Code = 'CHEMIN_REPERTOIRE_BASE_DOCUMENTS'");
sb.AppendLine($"DOCUMENT ROOT = {root}");
// 2. Un échantillon de documents (photos par extension).
var photos = Query(connection,
"SELECT DC_PKID, DC_Nom, DC_Chemin, DC_CleCategorie FROM Document " +
"WHERE DC_Nom LIKE '%.jpg' OR DC_Nom LIKE '%.jpeg' OR DC_Nom LIKE '%.png' OR DC_Nom LIKE '%.bmp'");
sb.AppendLine($"PHOTO CANDIDATES = {photos.Count}");
foreach (var p in photos.Take(6))
{
string pkid = p.GetValueOrDefault("DC_PKID", "");
string nom = p.GetValueOrDefault("DC_Nom", "");
string chemin = p.GetValueOrDefault("DC_Chemin", "");
string candidat = Combine(root, chemin, nom);
sb.AppendLine($" DC_PKID={pkid} DC_Nom={nom} DC_Chemin={chemin} candidate={candidat} exists={File.Exists(candidat)}");
}
}
catch (Exception ex)
{
sb.AppendLine($"ERROR: {ex.GetType().Name} {ex.Message}");
}
finally
{
await File.WriteAllTextAsync(OutputPath, sb.ToString());
}
}
private static string Scalar(OleDbConnection c, string sql)
{
using var cmd = new OleDbCommand(sql, c);
return cmd.ExecuteScalar()?.ToString() ?? "<null>";
}
private static List<Dictionary<string, string>> Query(OleDbConnection c, string sql)
{
var result = new List<Dictionary<string, string>>();
using var cmd = new OleDbCommand(sql, c);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? "" : reader.GetValue(i)?.ToString() ?? "";
}
result.Add(row);
}
return result;
}
private static string Combine(string root, string chemin, string nom)
{
if (string.IsNullOrWhiteSpace(root)) return "<no-root>";
string combined = string.IsNullOrWhiteSpace(chemin)
? nom
: System.IO.Path.Combine(chemin.Trim('\\', '/'), nom);
try
{
return System.IO.Path.GetFullPath(System.IO.Path.Combine(root, combined));
}
catch
{
return "<invalid>";
}
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath)) return null;
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch { return null; }
}
}
@@ -0,0 +1,339 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Services;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// SONDE DIAGNOSTIC (lecture seule) du MODÈLE EMAIL OMIKA : charge un vrai
/// <c>MailModele</c> d'OMIKA_TEST, sépare sujet/corps, liste les rubriques
/// <c>GetChampsMailAsync</c>, puis reproduit la substitution
/// <c>EmailCreationService</c> pour exposer la première divergence fonctionnelle.
/// N'écrit JAMAIS en base. Résultats dans artifacts/diag-email.txt.
/// </summary>
public class RapportEmailDiagnosticTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string ArtifactDir = @"D:\Avalonia\Omika\artifacts";
[Fact]
public async Task Diagnostic_Trace_Email_Omika()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA EMAIL — DIAGNOSTIC (read-only) ===");
TestConfig? config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await WriteArtifact("diag-email.txt", sb);
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
try
{
await connection.OpenAsync();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED (skip): {ex.Message}");
await WriteArtifact("diag-email.txt", sb);
return;
}
try
{
// 1. Liste des modèles email.
IReadOnlyList<IReadOnlyDictionary<string, object?>> modeles = await connection.QueryAsync(
"SELECT MM_PKID, MM_Nom, MM_Sujet, MM_MsgDoc, MM_Extra, MM_EnvoiMob FROM MailModele ORDER BY MM_PKID");
sb.AppendLine($"MAIL MODELES = {modeles.Count}");
long modeleChoisi = 0;
byte[]? corpsChoisi = null;
string? sujetChoisi = null;
string? extraChoisi = null;
foreach (var m in modeles)
{
byte[]? docx = m.GetValueOrDefault("MM_MsgDoc") as byte[];
string? sujet = m.GetValueOrDefault("MM_Sujet")?.ToString();
string? extra = m.GetValueOrDefault("MM_Extra")?.ToString();
string texte = docx is null ? string.Empty : DocxTextExtractor.Extraire(docx);
string combiné = (sujet ?? string.Empty) + "\n" + texte;
var jetons = RubriqueSubstitutionEngine.ExtractTokens(combiné);
bool interet = jetons.Count > 0;
sb.AppendLine($" MM_PKID={m.GetValueOrDefault("MM_PKID")} nom={Truncate(m.GetValueOrDefault("MM_Nom")?.ToString())} " +
$"sujet={Truncate(sujet, 40)} msgDoc={docx?.Length ?? 0}o extra={(extra is null ? "null" : extra.Length + "o")} " +
$"envoiMob={m.GetValueOrDefault("MM_EnvoiMob")} jetons={jetons.Count}");
if (interet && modeleChoisi == 0)
{
modeleChoisi = Convert.ToInt64(m.GetValueOrDefault("MM_PKID"));
corpsChoisi = docx;
sujetChoisi = sujet;
extraChoisi = extra;
}
// Préfère le modèle qui a un MM_Extra non vide (rubriques EXTRA réelles).
if (!string.IsNullOrWhiteSpace(extra))
{
modeleChoisi = Convert.ToInt64(m.GetValueOrDefault("MM_PKID"));
corpsChoisi = docx;
sujetChoisi = sujet;
extraChoisi = extra;
}
}
if (modeleChoisi == 0)
{
sb.AppendLine("NO MAIL MODEL WITH PLACEHOLDERS (skip).");
await WriteArtifact("diag-email.txt", sb);
return;
}
// 2. Modèle choisi : sujet + corps + extra.
string corpsTexte = corpsChoisi is null ? string.Empty : DocxTextExtractor.Extraire(corpsChoisi);
sb.AppendLine();
sb.AppendLine($"CHOSEN MM_PKID={modeleChoisi}");
sb.AppendLine($"MM_EXTRA = {extraChoisi}");
sb.AppendLine("── MM_EXTRA PARSED ──");
if (!string.IsNullOrWhiteSpace(extraChoisi))
{
foreach (RapportChampDto x in RubriqueExtraMobParser.Parser(extraChoisi))
{
sb.AppendLine($" extra rub={x.Rubrique} nom={Truncate(x.Nom, 25)} type={x.Type} typeMembre={x.TypeMembre} masque={x.MasqueSaisie} groupe={x.GroupeApp}");
}
}
sb.AppendLine($"── SUJET (brut) ──");
sb.AppendLine(sujetChoisi ?? "<null>");
sb.AppendLine($"── SUJET PLACEHOLDERS ──");
foreach (string j in RubriqueSubstitutionEngine.ExtractTokens(sujetChoisi))
{
sb.AppendLine($" ${j}$");
}
sb.AppendLine($"── CORPS PLACEHOLDERS ──");
foreach (string j in RubriqueSubstitutionEngine.ExtractTokens(corpsTexte))
{
sb.AppendLine($" ${j}$");
}
sb.AppendLine($"── CORPS (extrait, {corpsTexte.Length} chars) ──");
sb.AppendLine(corpsTexte.Length <= 1500 ? corpsTexte : corpsTexte[..1500] + "…");
// Structure DOCX : quels placeholders sont dans un TableRow (w:tbl) vs un paragraphe.
sb.AppendLine();
sb.AppendLine("── DOCX STRUCTURE (placeholder → dans table ?) ──");
if (corpsChoisi is not null)
{
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-email-modele.docx"), corpsChoisi);
using var docxStream = new MemoryStream(corpsChoisi);
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Open(docxStream, false);
var bodyEl = doc.MainDocumentPart!.Document.Body!;
foreach (var tr in bodyEl.Descendants<DocumentFormat.OpenXml.Wordprocessing.TableRow>())
{
string txt = string.Concat(tr.Descendants<DocumentFormat.OpenXml.Wordprocessing.Text>().Select(t => t.Text));
if (txt.Contains('$') || txt.Contains('<'))
{
sb.AppendLine($" TABLE ROW: {Truncate(txt, 120)}");
}
}
foreach (var p in bodyEl.Descendants<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
string txt = string.Concat(p.Descendants<DocumentFormat.OpenXml.Wordprocessing.Text>().Select(t => t.Text));
if (txt.Contains('$') || txt.Contains('<'))
{
bool dansTable = p.Ancestors<DocumentFormat.OpenXml.Wordprocessing.Table>().Any();
sb.AppendLine($" PARAGRAPH (dansTable={dansTable}): {Truncate(txt, 120)}");
}
}
}
// 3. Intervention avec données (photos etc.) — première intervention.
long interventionId = 0;
IReadOnlyList<IReadOnlyDictionary<string, object?>> intervs = await connection.QueryAsync(
"SELECT IN_PKID FROM Intervention");
foreach (var i in intervs)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("IN_PKID"));
break;
}
var parameters = new FakeApplicationParameters("C:\\Documents_Demo");
var repository = new OmikaRapportChampRepository(
connection, NullLogger<OmikaRapportChampRepository>.Instance, parameters);
// 4. GetChampsMailAsync réel.
IReadOnlyList<RapportChampDto> champs = await repository.GetChampsMailAsync(modeleChoisi, interventionId);
sb.AppendLine();
sb.AppendLine($"── GetChampsMailAsync ({champs.Count}) ──");
foreach (RapportChampDto c in champs)
{
sb.AppendLine($" rub={c.Rubrique} nom={Truncate(c.Nom, 25)} groupe={c.GroupeApp} " +
$"type={c.Type} typeMembre={c.TypeMembre} masque={c.MasqueSaisie} modif={c.Modifiable} " +
$"bd={c.DisponibleBase} listeMembre={c.ListeMembre.Count} " +
$"bdVal={(c.ListeDonnesBD is null ? "null" : c.ListeDonnesBD.Length + "o")} " +
$"mobVal={(c.ListeDonnesMob is null ? "null" : c.ListeDonnesMob.Length + "o")}");
foreach (RapportChampSousElementDto m in c.ListeMembre)
{
sb.AppendLine($" membre rub={m.Rubrique} type={m.Type} membreMob={m.MembreMob}");
}
if (!string.IsNullOrWhiteSpace(c.ListeDonnesBD) && c.ListeDonnesBD.Length < 300)
{
sb.AppendLine($" ListeDonnesBD = {c.ListeDonnesBD}");
}
}
// 5. Reproduction via le VRAI moteur Email (EmailDocxRenderer + DocxHtmlConverter).
string sujetFinal = SubstituerSujet(sujetChoisi, champs);
byte[] docxRendu = EmailDocxRenderer.Rendre(corpsChoisi ?? [], champs);
string corpsFinal = DocxHtmlConverter.Convertir(docxRendu);
string corpsTexteFinal = DocxTextExtractor.Extraire(docxRendu);
sb.AppendLine();
sb.AppendLine("── SUBJECT FINAL ──");
sb.AppendLine(sujetFinal);
sb.AppendLine("── BODY FINAL (HTML extrait) ──");
sb.AppendLine(corpsFinal.Length <= 1500 ? corpsFinal : corpsFinal[..1500] + "…");
// Restants.
sb.AppendLine();
sb.AppendLine("── UNRESOLVED $...$ (subject + corps texte rendu) ──");
var restants = RubriqueSubstitutionEngine.ExtractTokens(sujetFinal + "\n" + corpsTexteFinal);
foreach (string r in restants)
{
sb.AppendLine($" ${r}$");
}
sb.AppendLine($"UNRESOLVED COUNT = {restants.Count}");
sb.AppendLine($"BODY IS HTML = {(corpsFinal.Contains("<table", StringComparison.OrdinalIgnoreCase) || corpsFinal.Contains("<p", StringComparison.OrdinalIgnoreCase) ? "YES" : "NO")}");
sb.AppendLine($"RAW JSON IN EMAIL = {(sujetFinal.Contains('{') && sujetFinal.Contains('}') || corpsFinal.Contains("\"cheminComplet\"") ? "YES" : "NO")}");
await WriteArtifact("diag-email.txt", sb);
}
catch (Exception ex)
{
sb.AppendLine();
sb.AppendLine($"ERROR: {ex.GetType().Name}: {ex}");
await WriteArtifact("diag-email.txt", sb);
}
}
private static string SubstituerSujet(string? sujet, IReadOnlyList<RapportChampDto> champs)
{
if (string.IsNullOrEmpty(sujet))
{
return sujet ?? string.Empty;
}
var remplacements = new Dictionary<string, string>(StringComparer.Ordinal);
var connus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (RapportChampDto champ in champs)
{
if (string.IsNullOrWhiteSpace(champ.Rubrique))
{
continue;
}
connus.Add(champ.Rubrique);
foreach (RapportChampSousElementDto m in champ.ListeMembre)
{
if (!string.IsNullOrWhiteSpace(m.Rubrique)) connus.Add(m.Rubrique);
}
if (champ.ListeMembre is { Count: > 0 })
{
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterTableauTexte(champ);
}
else
{
string? valeur = champ.ListeDonnesMob ?? champ.ListeDonnesBD;
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterValeur(valeur, champ.TypeMembre ?? champ.Type, champ.MasqueSaisie);
}
}
var builder = new StringBuilder(sujet);
foreach (var paire in remplacements)
{
if (!string.IsNullOrEmpty(paire.Key)) builder.Replace(paire.Key, paire.Value);
}
foreach (string jeton in connus)
{
builder.Replace(jeton, string.Empty);
}
return builder.ToString();
}
private static async Task WriteArtifact(string name, StringBuilder sb)
{
Directory.CreateDirectory(ArtifactDir);
await File.WriteAllTextAsync(Path.Combine(ArtifactDir, name), sb.ToString());
}
private static string Truncate(string? s, int max = 60) => s is null ? "<null>" : (s.Length <= max ? s : s[..max] + "…");
private sealed class FakeApplicationParameters : IApplicationParameterRepository
{
private readonly string _root;
public FakeApplicationParameters(string root) => _root = root;
public Task<string?> GetValueAsync(string code, CancellationToken ct = default)
=> Task.FromResult<string?>(code == "CHEMIN_REPERTOIRE_BASE_DOCUMENTS" ? _root : null);
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
{
return null;
}
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
}
@@ -0,0 +1,392 @@
using System.IO.Compression;
using System.IO.Packaging;
using System.Text;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Services;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
using WP = DocumentFormat.OpenXml.Drawing.Wordprocessing;
namespace Omika.Tests.Connection;
/// <summary>
/// SONDE DIAGNOSTIC (lecture seule + génération en mémoire) : trace UNE génération
/// de rapport réelle de bout en bout pour expliquer pourquoi une photo sélectionnée
/// n'apparaît pas dans le PDF. Ne modifie JAMAIS la base. Écrit :
/// — artifacts/diag-photo.txt : trace textuelle complète ;
/// — artifacts/diag-modele.docx : modèle DOCX réel (copie) ;
/// — artifacts/diag-rapport.docx : DOCX intermédiaire généré (avant conversion PDF).
/// S'auto-ignore si la configuration locale est absente ou le serveur injoignable.
/// </summary>
public class RapportPhotoDiagnosticTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string ArtifactDir = @"D:\Avalonia\Omika\artifacts";
[Fact]
public async Task Diagnostic_Trace_Photo_Reelle()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA PHOTO — DIAGNOSTIC (read-only) ===");
TestConfig? config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await WriteArtifact("diag-photo.txt", sb);
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
try
{
await connection.OpenAsync();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED (skip): {Redact(ex.Message)}");
await WriteArtifact("diag-photo.txt", sb);
return;
}
try
{
// 1. Racine documentaire.
object? rootObj = await connection.ExecuteScalarAsync(
"SELECT PAP_Valeur FROM ParametreApplication WHERE PAP_Code = 'CHEMIN_REPERTOIRE_BASE_DOCUMENTS'");
string racine = rootObj?.ToString() ?? string.Empty;
sb.AppendLine($"DOCUMENT ROOT = {racine}");
var parameters = new FakeApplicationParameters(racine);
var repository = new OmikaRapportChampRepository(
connection, NullLogger<OmikaRapportChampRepository>.Instance, parameters);
// 2. Modèles contenant des rubriques photo.
IReadOnlyList<IReadOnlyDictionary<string, object?>> modeles = await connection.QueryAsync(
"SELECT MRP_PKID, MRP_Nom, MRP_Modele, MRP_Extra FROM ModeleRapport");
sb.AppendLine($"MODELES = {modeles.Count}");
long modelePhoto = 0;
byte[]? modeleDocx = null;
string? modeleTexte = null;
foreach (var m in modeles)
{
byte[]? docx = m.GetValueOrDefault("MRP_Modele") as byte[];
if (docx is null || docx.Length == 0)
{
continue;
}
string texte = DocxTextExtractor.Extraire(docx);
bool contientPhoto = texte.Contains("PHOTO", StringComparison.OrdinalIgnoreCase);
sb.AppendLine($" MODELE pkid={m.GetValueOrDefault("MRP_PKID")} nom={Truncate(m.GetValueOrDefault("MRP_Nom")?.ToString())} photos={contientPhoto}");
if (contientPhoto && modelePhoto == 0)
{
modelePhoto = Convert.ToInt64(m.GetValueOrDefault("MRP_PKID"));
modeleDocx = docx;
modeleTexte = texte;
}
}
if (modelePhoto == 0 || modeleDocx is null)
{
sb.AppendLine("NO PHOTO MODEL FOUND (skip).");
await WriteArtifact("diag-photo.txt", sb);
return;
}
sb.AppendLine();
sb.AppendLine($"CHOSEN MODEL pkid={modelePhoto}");
sb.AppendLine("── MODEL TEXT (photo lines) ──");
foreach (string ligne in (modeleTexte ?? string.Empty).Split('\n'))
{
if (ligne.Contains("PHOTO", StringComparison.OrdinalIgnoreCase)
|| ligne.Contains("IMAGE", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" {ligne.Trim()}");
}
}
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-modele.docx"), modeleDocx);
// 3. RubriquesDocument liées aux photos.
IReadOnlyList<IReadOnlyDictionary<string, object?>> rubriques = await connection.QueryAsync(
"SELECT RD_PKID, RD_Nom, RD_Rubrique, RD_TypeMembre, RD_MasqueSaisie, RD_Groupe, RD_MembreMob, RD_IdRubParent, RD_Ordre " +
"FROM RubriquesDocument WHERE RD_Groupe = 'PHOTO_INTER' ORDER BY RD_IdRubParent, RD_Ordre");
sb.AppendLine();
sb.AppendLine($"── RUBRIQUES PHOTO_INTER ({rubriques.Count}) ──");
foreach (var r in rubriques)
{
sb.AppendLine($" rub={r.GetValueOrDefault("RD_Rubrique")} type={r.GetValueOrDefault("RD_TypeMembre")} " +
$"masque={r.GetValueOrDefault("RD_MasqueSaisie")} membre={r.GetValueOrDefault("RD_MembreMob")} parent={r.GetValueOrDefault("RD_IdRubParent")}");
}
// 4. Intervention avec photos (celle qui possède réellement des .jpg).
IReadOnlyList<IReadOnlyDictionary<string, object?>> interventionsAvecPhotos = await connection.QueryAsync(
"SELECT DISTINCT DC_IDIntervention FROM Document WHERE DC_Nom LIKE '%.jpg' OR DC_Nom LIKE '%.png'");
long interventionId = 0;
foreach (var i in interventionsAvecPhotos)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("DC_IDIntervention"));
break;
}
if (interventionId == 0)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> toutes = await connection.QueryAsync(
"SELECT IN_PKID FROM Intervention");
foreach (var i in toutes)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("IN_PKID"));
break;
}
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> photos = await connection.QueryAsync(
"SELECT Document.DC_PKID, Document.GUID, Document.DC_Nom, Document.DC_DateFichier, Document.DC_Chemin, " +
"ParametreMetier.PA_Libelle AS CAT_Libelle " +
"FROM Document LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Document.DC_CleCategorie " +
"AND Document.DC_CleCategorie <> '' WHERE Document.DC_IDIntervention = ? ORDER BY Document.DC_PKID",
[interventionId]);
sb.AppendLine();
sb.AppendLine($"INTERVENTION pkid={interventionId} photosBrutes={photos.Count}");
foreach (var p in photos.Take(5))
{
sb.AppendLine($" DC_PKID={p.GetValueOrDefault("DC_PKID")} nom={p.GetValueOrDefault("DC_Nom")} cat={p.GetValueOrDefault("CAT_Libelle")}");
}
// 5. Champs complets via le repository (équivalent serveur).
IReadOnlyList<RapportChampDto> champs = await repository.GetChampsAsync(
modelePhoto, interventionId, toutesRub: true);
sb.AppendLine();
sb.AppendLine($"── CHAMPS ({champs.Count}) ──");
foreach (RapportChampDto c in champs)
{
bool photo = c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true
|| string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase);
if (photo)
{
sb.AppendLine($" rub={c.Rubrique} type={c.Type} typeMembre={c.TypeMembre} membre={c.MembreMob} " +
$"listeMembre={c.ListeMembre.Count} listeDonnesBD={(c.ListeDonnesBD ?? "").Length} chars");
foreach (RapportChampSousElementDto m in c.ListeMembre)
{
sb.AppendLine($" membre: rub={m.Rubrique} type={m.Type} typeMembre={m.TypeMembre} membreMob={m.MembreMob}");
}
if (!string.IsNullOrWhiteSpace(c.ListeDonnesBD))
{
sb.AppendLine($" listeDonnesBD JSON = {Truncate(c.ListeDonnesBD, 800)}");
}
}
}
// 6. Photo sélectionnée (première option photo).
RapportChampDto? photoChamp = champs.FirstOrDefault(c =>
string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase)
|| c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true);
string? cheminComplet = null;
if (photoChamp is not null)
{
if (photoChamp.ListeMembre is { Count: > 0 })
{
// TABPHOTO : ListeDonnesBD = JSON array.
using JsonDocument doc = JsonDocument.Parse(photoChamp.ListeDonnesBD ?? "[]");
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in doc.RootElement.EnumerateArray())
{
if (item.TryGetProperty("cheminComplet", out JsonElement cc) && cc.ValueKind == JsonValueKind.String)
{
cheminComplet = cc.GetString();
break;
}
}
}
}
else
{
cheminComplet = photoChamp.ListeDonnesBD;
}
}
sb.AppendLine();
sb.AppendLine($"PHOTO SELECTED = {(cheminComplet is null ? "NO" : "YES")}");
sb.AppendLine($"CHEMINCOMPLET = {cheminComplet}");
sb.AppendLine($"FILE EXISTS = {(cheminComplet is null ? "N/A" : File.Exists(cheminComplet))}");
if (cheminComplet is not null && File.Exists(cheminComplet))
{
byte[] premiers = new byte[Math.Min(12, new FileInfo(cheminComplet).Length)];
using (FileStream fs = File.OpenRead(cheminComplet))
{
_ = fs.Read(premiers, 0, premiers.Length);
}
sb.AppendLine($"MAGIC BYTES = {BitConverter.ToString(premiers)}");
}
// 7. Génération du DOCX intermédiaire (vraie image fichier).
sb.AppendLine();
sb.AppendLine("── RemplirRubriques ──");
byte[] docxRempli = RapportPdfService.RemplirRubriques(modeleDocx, champs);
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-rapport.docx"), docxRempli);
sb.AppendLine($"INTERMEDIATE DOCX = {docxRempli.Length} bytes");
InspecterDocx(sb, docxRempli, modeleTexte ?? string.Empty, champs);
await WriteArtifact("diag-photo.txt", sb);
}
catch (Exception ex)
{
sb.AppendLine();
sb.AppendLine($"ERROR: {ex.GetType().Name}: {ex}");
await WriteArtifact("diag-photo.txt", sb);
}
}
private static void InspecterDocx(
StringBuilder sb,
byte[] docx,
string modeleTexte,
IReadOnlyList<RapportChampDto> champs)
{
using var stream = new MemoryStream(docx);
using WordprocessingDocument document = WordprocessingDocument.Open(stream, false);
MainDocumentPart? main = document.MainDocumentPart;
if (main is null)
{
sb.AppendLine("INSPECT: MainDocumentPart missing");
return;
}
// Images dans le package (sous /word/media/).
sb.AppendLine();
sb.AppendLine("── word/media/* ──");
using (var pkgStream = new MemoryStream(docx))
using (Package package = Package.Open(pkgStream, FileMode.Open, FileAccess.Read))
{
var mediaParts = package.GetParts()
.Where(p => p.Uri.OriginalString.StartsWith("/word/media/", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var mediaPart in mediaParts)
{
long bytes = 0;
try { using var s = mediaPart.GetStream(); bytes = s.Length; } catch { }
sb.AppendLine($" media: uri={mediaPart.Uri} contentType={mediaPart.ContentType} bytes={bytes}");
}
sb.AppendLine($"IMAGE PARTS = {mediaParts.Count}");
// Relations d'images (document.xml.rels).
sb.AppendLine();
sb.AppendLine("── document.xml.rels (image relationships) ──");
var imageRelations = package
.GetPart(new Uri("/word/document.xml", UriKind.Relative))
.GetRelationshipsByType("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image")
.ToList();
foreach (var rel in imageRelations)
{
sb.AppendLine($" relId={rel.Id} target={rel.TargetUri}");
}
}
sb.AppendLine();
sb.AppendLine("── Drawing OpenXML ──");
int drawings = main.Document.Body!.Descendants<Drawing>().Count();
var extents = main.Document.Body.Descendants<WP.Extent>()
.Select(e => $"{e.Cx!.Value}x{e.Cy!.Value}").ToList();
sb.AppendLine($"DRAWINGS = {drawings}");
foreach (string extent in extents.Take(10))
{
sb.AppendLine($" extent: {extent}");
}
// Placeholder restants.
sb.AppendLine();
sb.AppendLine("── PLACEHOLDER REMAINS ──");
string texte = string.Concat(main.Document.Body.Descendants<Text>().Select(t => t.Text));
foreach (RapportChampDto c in champs)
{
if (c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true
|| string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" rub={c.Rubrique} remains={texte.Contains(c.Rubrique ?? "", StringComparison.Ordinal)}");
}
}
}
private static async Task WriteArtifact(string name, StringBuilder sb)
{
Directory.CreateDirectory(ArtifactDir);
await File.WriteAllTextAsync(Path.Combine(ArtifactDir, name), sb.ToString());
}
private static string Truncate(string? s, int max = 60) => s is null ? "<null>" : (s.Length <= max ? s : s[..max] + "…");
private static string Redact(string msg) => msg.Replace("Password", "[PWD]", StringComparison.OrdinalIgnoreCase);
private sealed class FakeApplicationParameters : IApplicationParameterRepository
{
private readonly string _root;
public FakeApplicationParameters(string root) => _root = root;
public Task<string?> GetValueAsync(string code, CancellationToken ct = default)
=> Task.FromResult<string?>(code == "CHEMIN_REPERTOIRE_BASE_DOCUMENTS" ? _root : null);
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
{
return null;
}
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
}
@@ -0,0 +1,150 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Omika.Core.Interfaces.Mobile;
using Omika.Data;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Repositories.Technical;
namespace Omika.Tests.Connection;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddOmikaData_RegistersWebServiceConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IWebServiceHfSqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaDataServiceConnection_RegistersServiceConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaDataServiceConnection();
var descriptor = services.Single(d => d.ServiceType == typeof(IServiceHfSqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaData_RegistersSyncTransferRepositoryAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(ISyncTransferRepository));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(SyncTransferRepository), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaDataSyncConnection_DoesNotRegisterSyncTransferRepository()
{
// L'ancienne connexion Sync unique ne doit plus brancher le repository :
// le repository est désormais multi-connexion (Service + WebService).
var services = new ServiceCollection();
services.AddOmikaDataSyncConnection();
Assert.DoesNotContain(services, d => d.ServiceType == typeof(ISyncTransferRepository));
}
[Fact]
public async Task AddOmikaData_ResolvesSyncTransferRepository_WithBothConnections()
{
// Vérifie que le DI résout ISyncTransferRepository avec les deux connexions
// explicites (Service pour TOSRV, WebService pour MobReception/MobLog).
var services = new ServiceCollection();
services.AddLogging();
// IConfiguration est requis par les Configure<IConfiguration> des options
// (HfsqlOptions, ServiceConnectionOptions, etc.) lors de la résolution.
// La section ServiceConnection doit être valide pour que le validateur
// (ServiceConnectionOptionsValidator) accepte la résolution de la connexion
// Service. Le mot de passe reste volontairement absent (optionnel).
var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
// Connexion Service (base TOSRV) : mot de passe volontairement absent
// (optionnel : null/vide/renseigné sont tous valides).
[$"{ServiceConnectionOptions.SectionName}:Server"] = "localhost",
[$"{ServiceConnectionOptions.SectionName}:Port"] = "49000",
[$"{ServiceConnectionOptions.SectionName}:DatabaseName"] = "BATIK.01_BLS",
[$"{ServiceConnectionOptions.SectionName}:Login"] = "service",
// Connexion WebService/Business (base MobReception/MobLog) : la
// résolution de IHfsqlConnection exige des options Hfsql valides.
[$"{HfsqlOptions.SectionName}:Server"] = "localhost",
[$"{HfsqlOptions.SectionName}:Port"] = "49000",
[$"{HfsqlOptions.SectionName}:DatabaseName"] = "BATIK_TEST",
[$"{HfsqlOptions.SectionName}:Login"] = "service"
})
.Build();
services.AddSingleton<Microsoft.Extensions.Configuration.IConfiguration>(configuration);
services.AddOmikaData();
services.AddOmikaDataServiceConnection();
using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISyncTransferRepository>();
Assert.IsType<SyncTransferRepository>(repo);
// Les deux connexions explicites sont résolvables dans le même scope.
var service = scope.ServiceProvider.GetRequiredService<IServiceHfSqlConnection>();
var web = scope.ServiceProvider.GetRequiredService<IWebServiceHfSqlConnection>();
Assert.NotNull(service);
Assert.NotNull(web);
}
[Fact]
public void AddOmikaData_RegistersHfsqlConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaData_RegistersConnectionFactoryAsSingleton()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnectionFactory));
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
Assert.Equal(typeof(OdbcConnectionFactory), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaData_RegistersOptionsValidator()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IValidateOptions<HfsqlOptions>));
Assert.Equal(typeof(HfsqlOptionsValidator), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaData_RegistersHfsqlOptions()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IConfigureOptions<HfsqlOptions>));
Assert.NotNull(descriptor);
}
}
@@ -0,0 +1,158 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class ServiceConnectionOptionsValidatorTests
{
private static ServiceConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BATIK.01_BLS",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = ServiceConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_NullPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_EmptyPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = "";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_WhitespacePassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = " ";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_SetPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = "secret";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new ServiceConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<ServiceConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<ServiceConnectionOptions> validator = new ServiceConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<ServiceConnectionOptions> validator = new ServiceConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,332 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class ServiceOleDbConnectionTests
{
private static ServiceConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BATIK.01_BLS",
Login = "susana",
Password = "secret",
FilePasswords = new ServiceFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForService_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BATIK.01_BLS", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForService_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForService(null!));
}
[Fact]
public void CreateForService_NullPassword_ProducesEmptyCredential()
{
var options = CreateOptions();
options.Password = null;
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
// La factory normalise null en chaîne vide (cohérent avec CreateForSync
// et CreateForLicense). Le builder OLEDB n'ajoute alors pas de clé
// "Password" à la chaîne de connexion.
Assert.Equal("", result.Credential);
}
[Fact]
public void CreateForService_EmptyPassword_ProducesEmptyCredential()
{
var options = CreateOptions();
options.Password = "";
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
Assert.Equal("", result.Credential);
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM TOSRV");
Assert.Single(rows);
Assert.Equal("TS-001", rows[0]["TS_JSON"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM TOSRV");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM TOSRV"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
Assert.Equal(ServiceConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BATIK.01_BLS", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BATIK.01_BLS";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "TS_JSON";
public override int GetOrdinal(string name) => name == "TS_JSON" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "TS-001";
public override object GetValue(int ordinal) => "TS-001";
public override int GetValues(object[] values)
{
values[0] = "TS-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,114 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class SyncConnectionOptionsValidatorTests
{
private static SyncConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_SYNC",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = SyncConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new SyncConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<SyncConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<SyncConnectionOptions> validator = new SyncConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<SyncConnectionOptions> validator = new SyncConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,305 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class SyncOleDbConnectionTests
{
private static SyncConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_SYNC",
Login = "susana",
Password = "secret",
FilePasswords = new SyncFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForSync_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForSync(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BASE_SYNC", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForSync_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForSync(null!));
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM TOSRV");
Assert.Single(rows);
Assert.Equal("TS-001", rows[0]["TS_JSON"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM TOSRV");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM TOSRV"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
Assert.Equal(SyncConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BASE_SYNC", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BASE_SYNC";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "TS_JSON";
public override int GetOrdinal(string name) => name == "TS_JSON" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "TS-001";
public override object GetValue(int ordinal) => "TS-001";
public override int GetValues(object[] values)
{
values[0] = "TS-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,85 @@
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests des contrats Clients : séparation liste / détail / création /
/// modification et absence de champs physiques.
/// </summary>
public class ClientContractTests
{
[Fact]
public void ClientSummaryDto_ContainsOnlyListFields()
{
var summary = new ClientSummaryDto
{
Id = "CL-001",
Code = "C001",
Nom = "Ville de Lyon",
Type = "Collectivité",
Statut = "Actif",
Ville = "Lyon",
Telephone = "04 00 00 00 00",
EstBloque = false,
EstArchive = false
};
Assert.Equal("CL-001", summary.Id);
Assert.Equal("Ville de Lyon", summary.Nom);
Assert.False(summary.EstArchive);
}
[Fact]
public void ClientDetailsDto_CanHoldCollections()
{
var details = new ClientDetailsDto
{
Id = "CL-001",
Nom = "Ville de Lyon",
Adresses = [new AdresseDto { Id = "AD-1", Etiquette = "Mairie" }],
Contacts = [new ContactDto { Id = "CT-1", Nom = "Dupont", Prenom = "Jean" }],
Sites = [new SiteDto { Id = "ST-1", Nom = "Hôtel de ville" }]
};
Assert.Single(details.Adresses);
Assert.Single(details.Contacts);
Assert.Single(details.Sites);
Assert.Equal("Hôtel de ville", details.Sites[0].Nom);
}
[Fact]
public void CreateClientRequest_DoesNotAcceptPhysicalIdOrAuditFields()
{
var request = new CreateClientRequest { Nom = "Client test" };
Assert.Null(request.AdressePrincipale);
Assert.Null(request.Coordonnees);
// Aucune propriété d'audit ou identifiant physique ne doit être acceptée.
foreach (var property in typeof(CreateClientRequest).GetProperties())
{
Assert.DoesNotContain("Pkid", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DateCreation", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("ModifiePar", property.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void UpdateClientRequest_AllowsOptionalBloquage()
{
var request = new UpdateClientRequest { Nom = "Renommé", EstBloque = true };
Assert.True(request.EstBloque);
Assert.Equal("Renommé", request.Nom);
}
[Fact]
public void SearchClientsRequest_HasNoPhysicalPrefix()
{
var request = new SearchClientsRequest { Nom = "Lyon", Statut = "Actif" };
Assert.Equal("Lyon", request.Nom);
Assert.Equal("Actif", request.Statut);
}
}
@@ -0,0 +1,124 @@
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Organisation;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests des contrats Organisation (collaborateurs) et de leur neutralité totale
/// vis-à-vis des tables physiques HFSQL.
/// </summary>
public class CollaborateurContractTests
{
[Fact]
public void CollaborateurDetailsDto_IsFreeOfPhysicalNames()
{
var dto = new CollaborateurDetailsDto
{
Id = "COL-001",
Nom = "Dupont",
Prenom = "Jean"
};
string[] forbidden =
[
"CL_PKID", "PKID", "CL_Nom", "CL_Prenom"
];
foreach (string name in forbidden)
{
Assert.DoesNotContain(name, dto.GetType().Name);
Assert.DoesNotContain(name, dto.Nom);
}
}
[Fact]
public void CollaborateurDetailsDto_CanCarryCoordonnees()
{
var dto = new CollaborateurDetailsDto
{
Id = "COL-001",
Coordonnees = new ContactDetailsDto { Email = "jean@example.com", Phone = "0102030405" }
};
Assert.Equal("jean@example.com", dto.Coordonnees.Email);
Assert.Equal("0102030405", dto.Coordonnees.Phone);
}
[Fact]
public void CollaborateurDetailsDto_CanCarryReferences()
{
var dto = new CollaborateurDetailsDto
{
Id = "COL-001",
Civilite = new ReferenceValueDto { Key = "M", Label = "M." },
Statut = new ReferenceValueDto { Key = "ACTIF", Label = "Actif" }
};
Assert.Equal("M.", dto.Civilite.Label);
Assert.Equal("Actif", dto.Statut.Label);
}
[Fact]
public void CollaborateurSummaryDto_IsFreeOfPhysicalNames()
{
var dto = new CollaborateurSummaryDto
{
Id = "COL-001",
NomAffiche = "Jean Dupont"
};
string[] forbidden =
[
"CL_PKID", "PKID", "CL_Nom", "CL_Prenom"
];
foreach (string name in forbidden)
{
Assert.DoesNotContain(name, dto.GetType().Name);
Assert.DoesNotContain(name, dto.NomAffiche);
}
}
[Fact]
public void CreateCollaborateurRequest_IsIdenticalForBothSchemas()
{
// Le même contrat sert pour l'ancien et le nouveau schéma.
var request = new CreateCollaborateurRequest
{
Code = "JD",
Nom = "Dupont",
Prenom = "Jean"
};
Assert.Equal("JD", request.Code);
Assert.Equal("Dupont", request.Nom);
}
[Fact]
public void UpdateCollaborateurRequest_IsIdenticalForBothSchemas()
{
var request = new UpdateCollaborateurRequest
{
Nom = "Martin",
Actif = false
};
Assert.Equal("Martin", request.Nom);
Assert.False(request.Actif);
}
[Fact]
public void SearchCollaborateurRequest_IsIdenticalForBothSchemas()
{
var request = new SearchCollaborateurRequest
{
Search = "Dupont",
EquipeId = "equipe-1",
Actif = true
};
Assert.Equal("Dupont", request.Search);
Assert.Equal("equipe-1", request.EquipeId);
Assert.True(request.Actif);
}
}
@@ -0,0 +1,81 @@
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests des contrats Interventions (résumé, détail, création, modification,
/// recherche) et de leur neutralité vis-à-vis du stockage.
/// </summary>
public class InterventionContractTests
{
[Fact]
public void InterventionSummaryDto_ContainsOnlyListFields()
{
var summary = new InterventionSummaryDto
{
Id = "IN-001",
Reference = "2026-0001",
Titre = "Dépannage chaudière",
Etat = "En cours",
Type = "Maintenance",
DatePrevueDebut = new DateTime(2026, 1, 15, 8, 0, 0)
};
Assert.Equal("2026-0001", summary.Reference);
Assert.Equal("Maintenance", summary.Type);
}
[Fact]
public void InterventionDetailsDto_HoldsActionsCompteRendusAndReferences()
{
var details = new InterventionDetailsDto
{
Id = "IN-001",
Etat = new ReferenceValueDto { Label = "En cours" },
CollaborateurIds = ["COL-1", "COL-2"],
Actions = [new ActionInterventionDto { Description = "Commande pièce", Terminee = true }],
ComptesRendus = [new CompteRenduDto { Contenu = "Chaudière relancée" }]
};
Assert.Equal(2, details.CollaborateurIds.Count);
Assert.Single(details.Actions);
Assert.True(details.Actions[0].Terminee);
Assert.Equal("Chaudière relancée", details.ComptesRendus[0].Contenu);
}
[Fact]
public void CreateInterventionRequest_DoesNotAcceptPhysicalFields()
{
var request = new CreateInterventionRequest { Titre = "Intervention", Reference = "2026-0002" };
foreach (var property in typeof(CreateInterventionRequest).GetProperties())
{
Assert.DoesNotContain("Pkid", property.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HfSql", property.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void UpdateInterventionRequest_AcceptsEtatAndDates()
{
var request = new UpdateInterventionRequest
{
Titre = "Renommage",
Etat = new ReferenceValueDto { Label = "Terminé" },
DateReelleFin = new DateTime(2026, 1, 16, 12, 0, 0)
};
Assert.Equal("Terminé", request.Etat.Label);
Assert.NotNull(request.DateReelleFin);
}
[Fact]
public void SearchInterventionsRequest_HasNoPhysicalPrefix()
{
var request = new SearchInterventionsRequest { Titre = "chaudière", CollaborateurId = "COL-1" };
Assert.Equal("chaudière", request.Titre);
Assert.Equal("COL-1", request.CollaborateurId);
}
}
@@ -0,0 +1,80 @@
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Observability.Diagnostic;
namespace Omika.Tests.Contracts.License;
/// <summary>
/// Tests de l'application du snapshot d'appareil à la requête d'activation (T17-C).
/// Vérifie que OsType/OsVersion/AppVersion/DeviceInfo sont remplis depuis la MÊME
/// source que le diagnostic, sans jamais exposer de DeviceId ni de secret.
/// </summary>
public class LicenseActivateRequestDeviceExtensionsTests
{
[Fact]
public void ApplyDeviceSnapshot_FillsOsVersionAppAndDeviceInfo()
{
var request = new LicenseActivateRequest
{
CodeClient = "C",
NumeroLicence = "L",
DeviceId = "D"
};
var device = new DeviceInfoSnapshot
{
OperatingSystem = "Android",
OperatingSystemVersion = "16",
AppVersion = "1.0.0",
DeviceManufacturer = "Xiaomi",
DeviceModel = "23113RKC6G"
};
LicenseActivateRequest result = request.ApplyDeviceSnapshot(device);
Assert.Equal("Android", result.OsType);
Assert.Equal("16", result.OsVersion);
Assert.Equal("1.0.0", result.AppVersion);
Assert.Equal("manufacturer=Xiaomi;model=23113RKC6G", result.DeviceInfo);
Assert.Null(result.NotificationKey);
Assert.Equal("D", result.DeviceId);
}
[Fact]
public void ApplyDeviceSnapshot_NullLeavesFieldsNull()
{
var request = new LicenseActivateRequest
{
CodeClient = "C",
NumeroLicence = "L",
DeviceId = "D"
};
LicenseActivateRequest result = request.ApplyDeviceSnapshot(null);
Assert.Null(result.OsType);
Assert.Null(result.OsVersion);
Assert.Null(result.AppVersion);
Assert.Null(result.DeviceInfo);
Assert.Null(result.NotificationKey);
}
[Fact]
public void ApplyDeviceSnapshot_NeverExposesDeviceIdInDeviceInfo()
{
var request = new LicenseActivateRequest
{
CodeClient = "C",
NumeroLicence = "L",
DeviceId = "secret-device-id"
};
var device = new DeviceInfoSnapshot
{
DeviceManufacturer = "Apple",
DeviceModel = "iPhone17,2"
};
LicenseActivateRequest result = request.ApplyDeviceSnapshot(device);
Assert.DoesNotContain("secret-device-id", result.DeviceInfo);
Assert.DoesNotContain("device-id", result.DeviceInfo);
}
}
@@ -0,0 +1,55 @@
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests du contrat de pagination : bornes, calcul du nombre total de pages et
/// indicateurs de navigation.
/// </summary>
public class PagedResultTests
{
[Fact]
public void PagedRequest_NormalizesPage_ToAtLeastOne()
{
var request = new PagedRequest { Page = 0, PageSize = 10 };
Assert.Equal(1, request.Page);
}
[Fact]
public void PagedRequest_ClampsPageSize()
{
var request = new PagedRequest { PageSize = PagedRequest.MaxPageSize + 1 };
Assert.Equal(PagedRequest.MaxPageSize, request.PageSize);
}
[Fact]
public void PagedRequest_ZeroPageSize_FallsBackToDefault()
{
var request = new PagedRequest { PageSize = 0 };
Assert.Equal(20, request.PageSize);
}
[Fact]
public void PagedResult_ComputesTotalPages_AndNavigation()
{
var items = new[] { "a", "b", "c", "d", "e" };
var result = new PagedResult<string>(items, Page: 1, PageSize: 2, TotalCount: 5, TotalPages: 3);
Assert.Equal(5, result.TotalCount);
Assert.Equal(3, result.TotalPages);
Assert.False(result.HasPreviousPage);
Assert.True(result.HasNextPage);
}
[Fact]
public void PagedResult_LastPage_HasNoNextPage()
{
var result = new PagedResult<string>([], Page: 2, PageSize: 2, TotalCount: 4, TotalPages: 2);
Assert.True(result.HasPreviousPage);
Assert.False(result.HasNextPage);
}
}
@@ -0,0 +1,70 @@
using Omika.Core.Contracts.Configuration;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests du parseur de paramètres métier, centrés sur la conversion des
/// couleurs WinDev (<c>sCouleur</c>, entier <c>0xRRGGBB</c> sérialisé en
/// décimal) vers le format hexadécimal <c>#RRGGBB</c> utilisé par l'UI mobile.
/// </summary>
public class ParametreMetierParametresParserTests
{
[Theory]
[InlineData("5288012", "#50B04C")] // « Fait » (vert)
[InlineData("15112707", "#E69A03")] // « Confirmé » (orange)
[InlineData("1393623", "#1543D7")] // « Intervention » (bleu)
[InlineData("0", "#000000")]
[InlineData("16777215", "#FFFFFF")]
public void ToHexColor_ConvertitEntierRgbEnHex(string winDevColor, string expected)
{
Assert.Equal(expected, ParametreMetierParametresParser.ToHexColor(winDevColor));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("abc")]
[InlineData("-1")]
[InlineData("-5288012")]
public void ToHexColor_ValeurInvalideOuSysteme_RetourneNull(string? winDevColor)
{
Assert.Null(ParametreMetierParametresParser.ToHexColor(winDevColor));
}
[Fact]
public void ToHexColor_AccepteUnNombreJsonSansDecimale()
{
// Le JSON WinDev peut sérialiser l'entier en nombre (sans guillemets).
Assert.Equal("#50B04C", ParametreMetierParametresParser.ToHexColor("5288012"));
}
[Fact]
public void ParseStatutRdv_ExtraitLaCouleur()
{
StatutRdvParametresDto? statut =
ParametreMetierParametresParser.ParseStatutRdv("{\"sCouleur\":\"15112707\"}");
Assert.NotNull(statut);
Assert.Equal("15112707", statut.Couleur);
}
[Fact]
public void ParseStatutRdv_JsonVide_RetourneNull()
{
Assert.Null(ParametreMetierParametresParser.ParseStatutRdv(""));
Assert.Null(ParametreMetierParametresParser.ParseStatutRdv(null));
Assert.Null(ParametreMetierParametresParser.ParseStatutRdv("not-json"));
}
[Fact]
public void ParseTypeRdv_ExtraitLaCouleur()
{
TypeRdvParametresDto? type =
ParametreMetierParametresParser.ParseTypeRdv("{\"sCouleur\":\"1393623\",\"Envoi_mobile\":true}");
Assert.NotNull(type);
Assert.Equal("1393623", type.Couleur);
Assert.True(type.EnvoiMobile);
}
}
@@ -0,0 +1,87 @@
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Planning;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests des contrats Planning (rendez-vous, participants, annulation) et de
/// leur neutralité totale vis-à-vis des tables physiques HFSQL.
/// </summary>
public class RendezVousContractTests
{
[Fact]
public void RendezVousDetailsDto_IsFreeOfPhysicalNames()
{
var dto = new RendezVousDetailsDto
{
Id = "RDV-001",
Titre = "Visite technique",
Debut = new DateTime(2026, 2, 1, 9, 0, 0),
Fin = new DateTime(2026, 2, 1, 11, 0, 0)
};
string[] forbidden =
[
"Planning_RDV", "RDV_Planning", "PL_PKID", "RDP_GUID", "PKID"
];
foreach (string name in forbidden)
{
Assert.DoesNotContain(name, dto.GetType().Name);
Assert.DoesNotContain(name, dto.Titre);
}
}
[Fact]
public void RendezVousDetailsDto_CanCarryCollaborateursAndContacts()
{
var dto = new RendezVousDetailsDto
{
Id = "RDV-001",
Collaborateurs =
[
new AffectationCollaborateurDto { CollaborateurId = "COL-1", Responsable = true },
new AffectationCollaborateurDto { CollaborateurId = "COL-2", TempsValide = true }
],
Contacts = [new ParticipantRendezVousDto { ContactId = "CT-9", PresenceConfirmee = true }]
};
Assert.Equal(2, dto.Collaborateurs.Count);
Assert.True(dto.Collaborateurs[0].Responsable);
Assert.Single(dto.Contacts);
Assert.True(dto.Contacts[0].PresenceConfirmee);
}
[Fact]
public void RendezVousDetailsDto_CanRepresentAnnulation()
{
var dto = new RendezVousDetailsDto
{
Id = "RDV-002",
Annulation = new AnnulationRendezVousDto
{
DateAnnulation = new DateTime(2026, 2, 1, 8, 0, 0),
Motif = new ReferenceValueDto { Label = "Client indisponible" },
Commentaire = "Reporter la visite"
}
};
Assert.NotNull(dto.Annulation);
Assert.Equal("Client indisponible", dto.Annulation.Motif.Label);
}
[Fact]
public void CreateRendezVousRequest_IsIdenticalForBothSchemas()
{
// Le même contrat sert pour l'ancien et le nouveau schéma.
var request = new CreateRendezVousRequest
{
Titre = "RDV",
Debut = new DateTime(2026, 3, 1, 9, 0, 0),
Fin = new DateTime(2026, 3, 1, 10, 0, 0)
};
Assert.Equal("RDV", request.Titre);
Assert.Equal(new DateTime(2026, 3, 1, 10, 0, 0), request.Fin);
}
}
@@ -0,0 +1,40 @@
using Omika.Core.Contracts.Security;
namespace Omika.Tests.Contracts.Security;
/// <summary>
/// Tests de sécurité du contrat <see cref="LoginRequest"/> : le mot de passe
/// ne doit jamais être exposé par <see cref="LoginRequest.ToString"/>.
/// </summary>
public class LoginRequestTests
{
[Fact]
public void ToString_NeverExposesPassword()
{
const string password = "MotDePasseUltraSecret123!";
var request = new LoginRequest { Login = "jean.dupont", Password = password };
string text = request.ToString();
Assert.DoesNotContain(password, text, StringComparison.Ordinal);
Assert.Contains(request.Login, text, StringComparison.Ordinal);
}
[Fact]
public void ToString_IndicatesWhetherPasswordIsPresent()
{
var withPassword = new LoginRequest { Login = "jean.dupont", Password = "secret" };
var withoutPassword = new LoginRequest { Login = "jean.dupont", Password = null };
Assert.Contains("HasPassword = True", withPassword.ToString(), StringComparison.Ordinal);
Assert.Contains("HasPassword = False", withoutPassword.ToString(), StringComparison.Ordinal);
}
[Fact]
public void ToString_EmptyPasswordIsTreatedAsAbsent()
{
var request = new LoginRequest { Login = "jean.dupont", Password = string.Empty };
Assert.Contains("HasPassword = False", request.ToString(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,328 @@
using Omika.Core.Contracts.Configuration;
namespace Omika.Tests.Contracts;
/// <summary>
/// Tests de la règle centrale d'obligation de saisie d'un type de temps sur un
/// rendez-vous (<see cref="TempsObligatoireRules"/>).
///
/// <para>
/// Les cas de référence sont ceux du métier : pause déjeuner autorisée dans les
/// rendez-vous de type intervention, plage horaire 12:00 → 15:00, saisie
/// obligatoire dès que le rendez-vous dépasse 30 minutes.
/// </para>
/// </summary>
public class TempsObligatoireRulesTests
{
private static readonly DateTime Jour = new(2026, 3, 10);
private static DateTime Heure(int heures, int minutes = 0) => Jour.AddHours(heures).AddMinutes(minutes);
/// <summary>Type de temps « pause déjeuner » : 12:00 → 15:00, minimum 10 minutes.</summary>
private static TypeTempsParametresDto PauseDejeuner(
string? tempsMin = "10",
string? obligatoire = "0030",
string? typeRdv = null,
string? heureDebut = "120000",
string? heureFin = "150000")
=> new()
{
Obligatoire = obligatoire,
HeureDebut = heureDebut,
HeureFin = heureFin,
TempsMin = tempsMin,
TypeRdv = typeRdv
};
/// <summary>Type de rendez-vous « intervention » exigeant une saisie de temps.</summary>
private static TypeRdvParametresDto TypeRdvIntervention(bool saisirTempsObligatoire = true)
=> new() { SaisirTempsObligatoire = saisirTempsObligatoire };
// ===== Déclenchement par le seuil et la plage horaire =====
[Fact]
public void EstObligatoire_RendezVous8h17h_QuiEnglobeLaPlage_EstObligatoire()
{
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(8), Heure(17));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous11h15h_QuiEnglobeLaPlage_EstObligatoire()
{
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(11), Heure(15));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous13h15h_CreneauLibreAvant_NAPasEteObligatoire()
{
// 12:00 → 13:00 reste libre : la pause a pu être prise avant le rendez-vous.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(13), Heure(15));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous11h14h_CreneauLibreApres_NAPasEteObligatoire()
{
// 14:00 → 15:00 reste libre : la pause peut venir après le rendez-vous.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(11), Heure(14));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous12h30_15h_CreneauLibre30Min_AvecMinimum10Min_NAPasEteObligatoire()
{
// 12:00 → 12:30 reste libre, soit 30 minutes : de quoi prendre une pause
// dont la durée minimale est de 10 minutes.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(tempsMin: "10"), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(12, 30), Heure(15));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous12h30_15h_CreneauLibre30Min_AvecMinimum1Heure_EstObligatoire()
{
// Mêmes horaires, mais une pause d'au moins 1 heure ne tient plus dans les
// 30 minutes libres avant le rendez-vous : le collaborateur doit la déclarer.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(tempsMin: "0100"), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(12, 30), Heure(15));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_RendezVous13h30_15h_CreneauLibre1h30_AvecMinimum1Heure_NAPasEteObligatoire()
{
// 12:00 → 13:30 reste libre (1 h 30) : la pause d'une heure peut y être prise.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(tempsMin: "0100"), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(13, 30), Heure(15));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_CreneauLibreInsuffisantDesDeuxCotes_EstObligatoire()
{
// 30 minutes avant et 30 minutes après, mais une pause d'une heure : aucun
// créneau libre n'est assez long, la saisie est obligatoire.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(tempsMin: "0100"), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(12, 30), Heure(14, 30));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_SansTempsMin_NAExigeQueSiAucunCreneauLibre()
{
TypeTempsParametresDto sansMinimum = PauseDejeuner(tempsMin: null);
// 5 minutes libres suffisent à pouvoir prendre le temps ailleurs.
Assert.False(TempsObligatoireRules.EstObligatoire(
sansMinimum, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(12, 5), Heure(15)));
// Aucun créneau libre : la saisie est obligatoire.
Assert.True(TempsObligatoireRules.EstObligatoire(
sansMinimum, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(11), Heure(16)));
}
// ===== Paramètres absents ou nuls : aucune restriction =====
[Fact]
public void EstObligatoire_SeuilVideOuZero_NAPasEteObligatoire()
{
Assert.False(TempsObligatoireRules.EstObligatoire(
PauseDejeuner(obligatoire: null), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(8), Heure(17)));
Assert.False(TempsObligatoireRules.EstObligatoire(
PauseDejeuner(obligatoire: "0"), TypeRdvIntervention(), "T.RDV.INTER", "Intervention",
Heure(8), Heure(17)));
}
[Fact]
public void EstObligatoire_RendezVousPlusCourtQueLeSeuil_NAPasEteObligatoire()
{
// 20 minutes de rendez-vous pour un seuil de 30 minutes.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(12), Heure(12, 20));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_TypeRdvSansSaisieObligatoire_NAPasEteObligatoire()
{
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(saisirTempsObligatoire: false), "T.RDV.INTER", "Intervention",
Heure(8), Heure(17));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_ParametresDuTypeDeTempsAbsents_NAPasEteObligatoire()
{
bool obligatoire = TempsObligatoireRules.EstObligatoire(
null, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(8), Heure(17));
Assert.False(obligatoire);
}
[Fact]
public void EstObligatoire_SansPlageHoraire_LeSeuilSeulDeclenche()
{
// Aucune borne d'horaire : aucune restriction, le seuil décide.
Assert.True(TempsObligatoireRules.EstObligatoire(
PauseDejeuner(heureDebut: null, heureFin: null), TypeRdvIntervention(),
"T.RDV.INTER", "Intervention", Heure(8), Heure(17)));
Assert.True(TempsObligatoireRules.EstObligatoire(
PauseDejeuner(heureDebut: "000000", heureFin: "000000"), TypeRdvIntervention(),
"T.RDV.INTER", "Intervention", Heure(8), Heure(17)));
}
// ===== Premier paramètre : type de rendez-vous autorisé =====
[Fact]
public void EstObligatoire_TypeDeTempsNonAutorisePourCeTypeDeRdv_NAPasEteObligatoire()
{
TypeTempsParametresDto pause = PauseDejeuner(typeRdv: "T.RDV.INTER");
Assert.True(TempsObligatoireRules.EstObligatoire(
pause, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(8), Heure(17)));
Assert.False(TempsObligatoireRules.EstObligatoire(
pause, TypeRdvIntervention(), "T.RDV.SAV", "SAV", Heure(8), Heure(17)));
}
[Fact]
public void EstObligatoire_TypeDeRdvAutoriseParSonLibelle_EstObligatoire()
{
// Génération Batik : le type de temps liste le libellé, le rendez-vous non
// plus (aucune clé métier disponible).
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(typeRdv: "Intervention"), TypeRdvIntervention(), null, "Intervention",
Heure(8), Heure(17));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_AncienCodeTypeAct_EstPrisEnCompte()
{
TypeTempsParametresDto pause = PauseDejeuner(typeRdv: null);
pause = pause with { TypeAct = "T.RDV.INTER" };
Assert.True(TempsObligatoireRules.EstObligatoire(
pause, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(8), Heure(17)));
}
[Fact]
public void EstAutorise_ListeVide_AutoriseTousLesTypesDeRdv()
{
Assert.True(TempsObligatoireRules.EstAutorise(PauseDejeuner(), "T.RDV.SAV", "SAV"));
Assert.True(TempsObligatoireRules.EstAutorise(null, "T.RDV.SAV", "SAV"));
Assert.True(TempsObligatoireRules.EstAutorise(PauseDejeuner(), null, null));
}
[Fact]
public void EstAutorise_TypeDeRdvIndetermine_NAInterditPasLeTypeDeTemps()
{
// Sans type de rendez-vous connu, aucune restriction ne peut être appliquée :
// le type de temps reste saisissable.
Assert.True(TempsObligatoireRules.EstAutorise(PauseDejeuner(typeRdv: "T.RDV.INTER"), null, null));
}
// ===== Plage horaire : bornes partielles, créneau libre =====
[Fact]
public void EstObligatoire_PlageSansHeureDeFin_SEnTendJusquEnFinDeJournee()
{
// Plage 12:00 → fin de journée : un rendez-vous 12:00 → 24:00 la couvre
// entièrement, la pause ne peut plus être prise ailleurs.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(heureDebut: "120000", heureFin: null), TypeRdvIntervention(),
"T.RDV.INTER", "Intervention", Heure(12), Heure(24));
Assert.True(obligatoire);
}
[Fact]
public void EstObligatoire_PlageQuiChevaucheMinuit_EstLueSurLeLendemain()
{
// Plage 22:00 → 02:00 (nuit) et rendez-vous 22:30 → 02:00 : il reste
// 22:00 → 22:30 (30 minutes) pour prendre le temps ailleurs.
TypeTempsParametresDto tempsNuit = PauseDejeuner(
heureDebut: "220000", heureFin: "020000", tempsMin: "10");
Assert.False(TempsObligatoireRules.EstObligatoire(
tempsNuit, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(22, 30), Heure(26)));
// Même plage, rendez-vous 22:00 → 02:00 : plus aucune place libre.
Assert.True(TempsObligatoireRules.EstObligatoire(
tempsNuit, TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(22), Heure(26)));
}
[Fact]
public void PlusGrandCreneauLibreMinutes_RetourneLePlusGrandCote()
{
Assert.Equal(60, TempsObligatoireRules.PlusGrandCreneauLibreMinutes(
PauseDejeuner(), Heure(13), Heure(15)));
Assert.Equal(120, TempsObligatoireRules.PlusGrandCreneauLibreMinutes(
PauseDejeuner(), Heure(14), Heure(16)));
Assert.Equal(0, TempsObligatoireRules.PlusGrandCreneauLibreMinutes(
PauseDejeuner(), Heure(11), Heure(16)));
}
[Fact]
public void PlusGrandCreneauLibreMinutes_SansPlageHoraire_EstIndetermine()
{
Assert.Null(TempsObligatoireRules.PlusGrandCreneauLibreMinutes(
PauseDejeuner(heureDebut: null, heureFin: null), Heure(8), Heure(17)));
Assert.Null(TempsObligatoireRules.PlusGrandCreneauLibreMinutes(null, Heure(8), Heure(17)));
}
[Fact]
public void PlageTexte_FormateLaPlageAutorisee()
{
Assert.Equal("12:00–15:00", TempsObligatoireRules.PlageTexte(PauseDejeuner()));
Assert.Null(TempsObligatoireRules.PlageTexte(PauseDejeuner(heureDebut: null, heureFin: null)));
Assert.Null(TempsObligatoireRules.PlageTexte(null));
}
[Fact]
public void PlusGrandCreneauLibreMinutes_RendezVousHorsPlage_LaisseTouteLaPlage()
{
// Rendez-vous du matin : toute la plage 12:00 → 15:00 reste libre.
Assert.Equal(180, TempsObligatoireRules.PlusGrandCreneauLibreMinutes(
PauseDejeuner(), Heure(8), Heure(11)));
}
[Fact]
public void EstObligatoire_RendezVousTermineAvantLaPlage_NAPasEteObligatoire()
{
// Matinée 8:00 → 9:00 (plus d'une heure de rendez-vous) : la pause reste
// possible dans la plage horaire, elle n'est donc pas obligatoire.
bool obligatoire = TempsObligatoireRules.EstObligatoire(
PauseDejeuner(), TypeRdvIntervention(), "T.RDV.INTER", "Intervention", Heure(8), Heure(9));
Assert.False(obligatoire);
}
}
@@ -0,0 +1,64 @@
using Omika.App.Controls;
using Xunit;
namespace Omika.Tests.Controls;
/// <summary>
/// Règle d'ajout du texte dicté : le texte reconnu est toujours AJOUTÉ À LA FIN
/// du contenu du champ, sans jamais écraser ce qui était déjà saisi ou dicté.
/// </summary>
public sealed class DictationTextMergerTests
{
[Fact]
public void ChampVide_RecoitLaDictee()
=> Assert.Equal("Remplacement de la pièce", DictationTextMerger.Append(null, "Remplacement de la pièce"));
[Fact]
public void ChampVideAvecEspaces_RecoitLaDicteeSeule_Detoureee()
=> Assert.Equal("Paris", DictationTextMerger.Append(" ", " Paris "));
[Fact]
public void TexteExistant_AjouteAvecUneEspace()
=> Assert.Equal(
"Travaux réalisés. Remplacement de la pièce.",
DictationTextMerger.Append("Travaux réalisés.", "Remplacement de la pièce."));
[Fact]
public void TexteQuiFinitParUneEspace_PasDeDoubleEspace()
=> Assert.Equal("Travaux réalisés. Remplacement.", DictationTextMerger.Append("Travaux réalisés. ", "Remplacement."));
[Fact]
public void TexteQuiFinitParUnRetourLigne_PasDEspaceAjoutee()
=> Assert.Equal("Ligne 1\nLigne 2", DictationTextMerger.Append("Ligne 1\n", "Ligne 2"));
[Fact]
public void DicteeVide_NeModifieRien()
=> Assert.Equal("Travaux", DictationTextMerger.Append("Travaux", " "));
[Fact]
public void DicteeNulle_NeModifieRien()
=> Assert.Equal("Travaux", DictationTextMerger.Append("Travaux", null));
[Fact]
public void DicteeNulleSurChampVide_RendUneChaineVide()
=> Assert.Equal(string.Empty, DictationTextMerger.Append(null, null));
[Fact]
public void DicteeAvecRetoursDeBord_EstDetoureee()
=> Assert.Equal("Note", DictationTextMerger.Append(string.Empty, " Note\n"));
[Fact]
public void DicteeMultiLignes_ConserveLesRetoursInternes()
=> Assert.Equal("A\nB", DictationTextMerger.Append(null, "A\nB"));
[Fact]
public void AjoutsSuccessifs_NePerdentAucunePhrase()
{
// Cas d'usage réel : un compte rendu dicté en trois fois.
string texte = DictationTextMerger.Append(null, "Première phrase.");
texte = DictationTextMerger.Append(texte, "Deuxième phrase.");
texte = DictationTextMerger.Append(texte, "Troisième phrase.");
Assert.Equal("Première phrase. Deuxième phrase. Troisième phrase.", texte);
}
}
@@ -0,0 +1,120 @@
using Avalonia;
using Omika.App.Controls;
using Xunit;
namespace Omika.Tests.Controls;
/// <summary>
/// Tests de la machine à états du tracé de signature (mono-pointeur). La logique
/// est extraite du contrôle visuel <see cref="SignaturePad"/> pour être testable
/// sans plateforme de rendu Avalonia (environnement headless).
/// </summary>
public sealed class SignatureStrokeTrackerTests
{
[Fact]
public void TryBeginStroke_PremierPointeur_ActiveLeTrait()
{
var tracker = new SignatureStrokeTracker();
bool started = tracker.TryBeginStroke(pointerId: 1, new Point(5, 5));
Assert.True(started);
Assert.True(tracker.IsDrawing);
Assert.Equal(1, tracker.ActivePointerId);
Assert.Single(tracker.Points);
Assert.Equal(new Point(5, 5), tracker.Points[0]);
}
[Fact]
public void TryBeginStroke_SecondPointeurPendantTrait_EstIgnore()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(5, 5));
bool started = tracker.TryBeginStroke(pointerId: 2, new Point(99, 99));
Assert.False(started);
Assert.Equal(1, tracker.ActivePointerId);
Assert.Single(tracker.Points);
Assert.Equal(new Point(5, 5), tracker.Points[0]);
}
[Fact]
public void TryAddPoint_PointeurActif_AjoutePointEtRetournePrecedent()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(0, 0));
Point? previous = tracker.TryAddPoint(pointerId: 1, new Point(10, 20));
Assert.True(previous.HasValue);
Assert.Equal(new Point(0, 0), previous!.Value);
Assert.Equal(2, tracker.Points.Count);
Assert.Equal(new Point(10, 20), tracker.Points[1]);
}
[Fact]
public void TryAddPoint_PointeurNonActif_EstIgnore()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(0, 0));
Point? previous = tracker.TryAddPoint(pointerId: 2, new Point(99, 99));
Assert.Null(previous);
Assert.Single(tracker.Points);
}
[Fact]
public void EndStroke_ReinitialiseEtatComplet()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(0, 0));
tracker.TryAddPoint(pointerId: 1, new Point(10, 10));
tracker.EndStroke();
Assert.False(tracker.IsDrawing);
Assert.Null(tracker.ActivePointerId);
Assert.Empty(tracker.Points);
}
[Fact]
public void NouveauTraitApresEndStroke_Fonctionne()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(0, 0));
tracker.EndStroke();
bool started = tracker.TryBeginStroke(pointerId: 3, new Point(7, 7));
Assert.True(started);
Assert.Equal(3, tracker.ActivePointerId);
Assert.Single(tracker.Points);
Assert.Equal(new Point(7, 7), tracker.Points[0]);
}
[Fact]
public void IsActivePointer_MauvaisPointeur_NeTerminePasLeTraitActif()
{
var tracker = new SignatureStrokeTracker();
tracker.TryBeginStroke(pointerId: 1, new Point(0, 0));
// Le relâchement d'un mauvais PointerId (second doigt) ne doit pas être
// interprété comme la fin du trait actif.
Assert.True(tracker.IsActivePointer(1));
Assert.False(tracker.IsActivePointer(2));
Assert.True(tracker.IsDrawing);
}
[Fact]
public void TryAddPoint_SansTraitActif_RetourneNull()
{
var tracker = new SignatureStrokeTracker();
Point? previous = tracker.TryAddPoint(pointerId: 1, new Point(10, 10));
Assert.Null(previous);
Assert.False(tracker.IsDrawing);
}
}
@@ -0,0 +1,217 @@
using Omika.Data.Repositories;
namespace Omika.Tests.Data;
/// <summary>
/// Tests de la résolution répertoire par répertoire des documents (ancres #id).
/// Le chemin est reconstruit depuis la structure Omika :
/// <c>[LI_CodeLieu] #[LI_PKID]\[CL_Nom] #[CL_PKID]\[IN_RefInterne] #[IN_PKID]\$DC_Categorie_Id$</c>.
/// Objectif : retrouver le répertoire existant même si un libellé a été modifié ou
/// contient des espaces en trop, sans jamais créer de doublon — et sans jamais
/// lancer de recherche sur le chemin complet.
/// </summary>
public class DocumentAnchoredPathTests : IDisposable
{
private const string ExpectedPath =
@"402 ORCHIDEES 2 #3512\Dupont #12\INT-0001 #3401\Photos #46";
private readonly string _root;
public DocumentAnchoredPathTests()
{
_root = Path.Combine(Path.GetTempPath(), "omika-anchors", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
// --- Extraction de l'ancre ------------------------------------------------
[Fact]
public void TryParseAnchor_ExtraitLeDernierIdentifiant()
{
bool parsed = DocumentPathResolver.TryParseAnchor("Dupont #2 #3512", out string label, out long id);
Assert.True(parsed);
Assert.Equal(3512, id);
Assert.Equal("Dupont #2", label);
}
[Fact]
public void TryParseAnchor_TolereLesEspacesAutourDuDiese()
{
Assert.True(DocumentPathResolver.TryParseAnchor("Photos # 46", out _, out long id1));
Assert.Equal(46, id1);
Assert.True(DocumentPathResolver.TryParseAnchor("Photos #46", out _, out long id2));
Assert.Equal(46, id2);
}
[Fact]
public void TryParseAnchor_SansIdentifiant_RetourneFaux()
{
Assert.False(DocumentPathResolver.TryParseAnchor("Intervention", out string label, out long id));
Assert.Equal(0, id);
Assert.Equal("Intervention", label);
}
[Fact]
public void NormalizeSegmentName_CompacteLesEspacesEtLaCasse()
{
Assert.Equal(
DocumentPathResolver.NormalizeSegmentName("402 ORCHIDEES 2 #3512"),
DocumentPathResolver.NormalizeSegmentName("402 ORCHIDEES 2 #3512"));
}
// --- Recherche du fichier -------------------------------------------------
[Fact]
public void RetrouveLeFichier_MalgreUnEspaceDoubleDansLeRepertoireRacine()
{
// Le disque porte « 402 ORCHIDEES 2 #3512 » (deux espaces), la base produit
// « 402 ORCHIDEES 2 #3512 » (un seul).
string actualDirectory = CreateDirectoryWithFile(@"402 ORCHIDEES 2 #3512\Dupont #12\INT-0001 #3401\Photos #46");
string? found = DocumentPathResolver.ResolveAnchoredDocumentFile(
_root, ExpectedPath, "photo.jpg", Guid.Empty);
Assert.NotNull(found);
Assert.Equal(
Path.GetFullPath(Path.Combine(actualDirectory, "photo.jpg")),
Path.GetFullPath(found!));
}
[Fact]
public void RetrouveLeFichier_MalgreUnLibelleModifie()
{
// Le client a été renommé : le disque porte « Dupont SA #12 », la base produit
// « Dupont #12 ». L'ancre #12 reste identique.
string actualDirectory = CreateDirectoryWithFile(@"402 ORCHIDEES 2 #3512\Dupont SA #12\INT-0001 #3401\Photos #46");
string? found = DocumentPathResolver.ResolveAnchoredDocumentFile(
_root, ExpectedPath, "photo.jpg", Guid.Empty);
Assert.NotNull(found);
Assert.Equal(
Path.GetFullPath(Path.Combine(actualDirectory, "photo.jpg")),
Path.GetFullPath(found!));
}
[Fact]
public void RetrouveLeFichier_QuandLeSegmentDeCategorieEstAbsent()
{
// Document historique : pas de sous-répertoire de catégorie sur le disque.
string interventionDirectory = CreateDirectoryWithFile(@"402 ORCHIDEES 2 #3512\Dupont #12\INT-0001 #3401");
string? found = DocumentPathResolver.ResolveAnchoredDocumentFile(
_root, ExpectedPath, "photo.jpg", Guid.Empty);
Assert.NotNull(found);
Assert.Equal(
Path.GetFullPath(Path.Combine(interventionDirectory, "photo.jpg")),
Path.GetFullPath(found!));
}
[Fact]
public void NeDescendPas_QuandUnNiveauIntermediaireManque()
{
// Le répertoire du client est absent : la descente doit s'arrêter là et ne
// jamais partir en recherche sur le chemin complet.
CreateDirectoryWithFile(@"402 ORCHIDEES 2 #3512\INT-0001 #3401\Photos #46");
string? found = DocumentPathResolver.ResolveAnchoredDocumentFile(
_root, ExpectedPath, "photo.jpg", Guid.Empty);
Assert.Null(found);
}
[Fact]
public void NeConfondPasDeuxIdentifiantsProches()
{
// #3512 attendu, le disque porte #13512 : aucune correspondance.
CreateDirectoryWithFile(@"402 ORCHIDEES 2 #13512\Dupont #12\INT-0001 #3401\Photos #46");
string? found = DocumentPathResolver.ResolveAnchoredDocumentFile(
_root, ExpectedPath, "photo.jpg", Guid.Empty);
Assert.Null(found);
}
// --- Création : réutilisation sans doublon --------------------------------
[Fact]
public void Creation_ReutiliseLeRepertoireExistant()
{
string existing = CreateDirectoryWithFile(@"402 ORCHIDEES 2 #3512\Dupont SA #12\INT-0001 #3401\Photos #46");
int directoriesBefore = Directory.GetDirectories(_root, "*", SearchOption.AllDirectories).Length;
DocumentPathResolver.AnchorResolution resolution = DocumentPathResolver.EnsureAnchoredDirectory(
_root, DocumentPathResolver.ParseAnchors(ExpectedPath));
// Le répertoire réel (deux espaces, libellé « Dupont SA ») est réutilisé tel quel.
Assert.NotNull(resolution.RelativeDirectory);
Assert.Equal(
Path.GetFullPath(existing),
Path.GetFullPath(Path.Combine(_root, resolution.RelativeDirectory!)));
// Aucun répertoire concurrent n'a été créé.
Assert.Equal(directoriesBefore, Directory.GetDirectories(_root, "*", SearchOption.AllDirectories).Length);
Assert.False(Directory.Exists(Path.Combine(_root, @"402 ORCHIDEES 2 #3512")));
}
[Fact]
public void Creation_AjouteUniquementLesSegmentsManquants()
{
// Client et intervention existent, la catégorie manque.
string interventionDirectory = Path.Combine(_root, @"402 ORCHIDEES 2 #3512\Dupont #12\INT-0001 #3401");
Directory.CreateDirectory(interventionDirectory);
Directory.CreateDirectory(Path.Combine(_root, @"402 ORCHIDEES 2 #3512\Dupont #12"));
DocumentPathResolver.AnchorResolution resolution = DocumentPathResolver.EnsureAnchoredDirectory(
_root, DocumentPathResolver.ParseAnchors(ExpectedPath));
Assert.True(resolution.IsComplete);
Assert.Equal(4, resolution.ResolvedDepth);
string expectedFinal = Path.Combine(interventionDirectory, "Photos #46");
Assert.Equal(Path.GetFullPath(expectedFinal), Path.GetFullPath(resolution.FullDirectory!));
Assert.True(Directory.Exists(expectedFinal));
}
[Fact]
public void Creation_DepuisUneRacineVide_CreeTouteLArborescence()
{
DocumentPathResolver.AnchorResolution resolution = DocumentPathResolver.EnsureAnchoredDirectory(
_root, DocumentPathResolver.ParseAnchors(ExpectedPath));
Assert.True(resolution.IsComplete);
Assert.Equal(
Path.GetFullPath(Path.Combine(_root, ExpectedPath)),
Path.GetFullPath(resolution.FullDirectory!));
}
[Fact]
public void ParseAnchors_IgnoreLesSeparateursMultiples()
{
IReadOnlyList<DocumentPathResolver.AnchoredSegment> segments =
DocumentPathResolver.ParseAnchors(@"\\402 ORCHIDEES 2 #3512\\Dupont #12\");
Assert.Equal(2, segments.Count);
Assert.Equal(3512, segments[0].Id);
Assert.Equal(12, segments[1].Id);
}
private string CreateDirectoryWithFile(string relativeDirectory)
{
string directory = Path.Combine(_root, relativeDirectory);
Directory.CreateDirectory(directory);
File.WriteAllBytes(Path.Combine(directory, "photo.jpg"), [1, 2, 3]);
return directory;
}
}
@@ -0,0 +1,69 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
namespace Omika.Tests.Domain;
/// <summary>
/// Tests de l'entité métier canonique <see cref="Client"/> : indépendance vis-à
/// vis du stockage HFSQL et portage des collections sans table de liaison.
/// </summary>
public class ClientDomainTests
{
[Fact]
public void Client_DoesNotExposePhysicalColumnNames()
{
var client = new Client();
string[] forbidden = ["CL_PKID", "CL_IDType", "CL_CleType", "CL_IDStatut", "CL_CleStatut"];
foreach (var property in client.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
[Fact]
public void Client_CanCarryTypeStatutAndFormeJuridiqueAsReferenceValues()
{
var client = new Client
{
Nom = "Ville de Lyon",
Type = new ReferenceValue("Collectivité") { Key = "COLLECTIVITE" },
Statut = new ReferenceValue("Actif") { LegacyId = 5 },
FormeJuridique = new ReferenceValue("Commune")
};
Assert.Equal("Collectivité", client.Type!.Label);
Assert.Equal("Actif", client.Statut!.Label);
Assert.Equal("Commune", client.FormeJuridique!.Label);
}
[Fact]
public void Client_CanCarrySourceReferenceWithoutHfSqlName()
{
var client = new Client
{
SourceReference = LegacyEntityReference.FromLegacyId(12_345)
};
Assert.Equal(DatabaseSchemaVersion.Batik, client.SourceReference!.Source);
Assert.Equal(12_345, client.SourceReference.NumericId);
}
[Fact]
public void Client_CollectionsAreControlledByAddMethods()
{
var client = new Client();
client.AddAdresse(new Adresse { Etiquette = "Mairie" });
client.AddContact(new Contact { Nom = "Dupont" });
client.AddSite(new Site { Nom = "Hôtel de ville" });
client.AddReferenceExterne(new ExternalReference("Batigest", ExternalId: "X-1"));
Assert.Single(client.Adresses);
Assert.Single(client.Contacts);
Assert.Single(client.Sites);
Assert.Single(client.ReferencesExternes);
}
}
@@ -0,0 +1,57 @@
using Omika.Core.Domain.Exceptions;
using Omika.Core.Domain.ValueObjects;
namespace Omika.Tests.Domain;
/// <summary>
/// Tests de l'objet de valeur <see cref="DateTimeRange"/>.
/// </summary>
public class DateTimeRangeTests
{
[Fact]
public void DateTimeRange_RejectsEndBeforeStart()
{
var start = new DateTime(2026, 1, 10, 9, 0, 0);
var end = new DateTime(2026, 1, 10, 8, 0, 0);
Assert.Throws<DomainValidationException>(() => new DateTimeRange(start, end));
}
[Fact]
public void DateTimeRange_AcceptsEqualDates()
{
var instant = new DateTime(2026, 1, 10, 9, 0, 0);
var range = new DateTimeRange(instant, instant);
Assert.Equal(TimeSpan.Zero, range.Duration);
}
[Fact]
public void DateTimeRange_ComputesDurationAndContains()
{
var range = new DateTimeRange(
new DateTime(2026, 1, 10, 9, 0, 0),
new DateTime(2026, 1, 10, 11, 30, 0));
Assert.Equal(TimeSpan.FromMinutes(150), range.Duration);
Assert.True(range.Contains(new DateTime(2026, 1, 10, 10, 0, 0)));
Assert.False(range.Contains(new DateTime(2026, 1, 10, 12, 0, 0)));
}
[Fact]
public void DateTimeRange_DetectsOverlap()
{
var a = new DateTimeRange(
new DateTime(2026, 1, 10, 9, 0, 0),
new DateTime(2026, 1, 10, 11, 0, 0));
var b = new DateTimeRange(
new DateTime(2026, 1, 10, 10, 0, 0),
new DateTime(2026, 1, 10, 12, 0, 0));
var c = new DateTimeRange(
new DateTime(2026, 1, 10, 13, 0, 0),
new DateTime(2026, 1, 10, 14, 0, 0));
Assert.True(a.Overlaps(b));
Assert.False(a.Overlaps(c));
}
}
@@ -0,0 +1,54 @@
using Omika.Core.Domain.Entities.Interventions;
using Omika.Core.Domain.ValueObjects;
namespace Omika.Tests.Domain;
/// <summary>
/// Tests de l'entité métier canonique <see cref="Intervention"/>.
/// </summary>
public class InterventionDomainTests
{
[Fact]
public void Intervention_UsesReferenceValuesForTypeEtatPriorite()
{
var intervention = new Intervention
{
Type = new ReferenceValue("Maintenance") { Key = "MAINT" },
Etat = new ReferenceValue("En cours"),
Priorite = new ReferenceValue("Haute") { LegacyId = 3 }
};
Assert.Equal("MAINT", intervention.Type!.Key);
Assert.Equal("En cours", intervention.Etat!.Label);
Assert.Equal(3, intervention.Priorite!.LegacyId);
}
[Fact]
public void Intervention_CanCarryCollaborateursActionsAndComptesRendus()
{
var intervention = new Intervention();
intervention.AddCollaborateur(Guid.NewGuid());
intervention.AddContact(Guid.NewGuid());
intervention.AddAction(new ActionIntervention { Description = "Préparer le plan", Terminee = false });
intervention.AddCompteRendu(new CompteRendu { Contenu = "Site inspecté" });
Assert.Single(intervention.CollaborateurIds);
Assert.Single(intervention.ContactIds);
Assert.Single(intervention.Actions);
Assert.Single(intervention.ComptesRendus);
}
[Fact]
public void Intervention_DoesNotExposeZombieFields()
{
var intervention = new Intervention();
string[] forbidden = ["Pkid", "IdStatut", "CleStatut", "CleType", "RDP_"];
foreach (var property in intervention.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,181 @@
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Planning;
using Omika.Core.Mapping;
using Omika.Core.Domain.Entities.Planning;
using Omika.Core.Domain.ValueObjects;
namespace Omika.Tests.Domain;
/// <summary>
/// Scénario de compatibilité des rendez-vous : le même
/// <see cref="RendezVousDetailsDto"/> doit pouvoir représenter un rendez-vous de
/// l'ancien schéma (Batik) et du nouveau schéma (Omika), avec ou sans contacts,
/// avec plusieurs collaborateurs, et avec des informations absentes selon la
/// version.
/// </summary>
public class RendezVousCrossSchemaTests
{
private static RendezVous BuildOldSchemaRendezVous()
{
var rendezVous = new RendezVous
{
Titre = "RDV Batik",
Description = null, // absent dans l'ancien modèle
Periode = new DateTimeRange(new DateTime(2026, 2, 10, 9, 0, 0), new DateTime(2026, 2, 10, 10, 0, 0)),
TypeActivite = new ReferenceValue("Visite") { LegacyId = 3 },
Statut = new ReferenceValue("Planifié") { LegacyId = 1 },
Couleur = "#FF0000",
EstTouteLaJournee = false,
EstArchive = false
};
rendezVous.AddCollaborateur(new AffectationCollaborateur
{
CollaborateurId = Guid.NewGuid(),
NomAffiche = "Dupont Jean",
Responsable = true,
Statut = new ReferenceValue("Affecté") { LegacyId = 2 },
TempsValide = false
});
rendezVous.AddCollaborateur(new AffectationCollaborateur
{
CollaborateurId = Guid.NewGuid(),
NomAffiche = "Martin Claire",
Responsable = false,
TempsValide = false
});
rendezVous.AddContact(new ParticipantRendezVous
{
ContactId = Guid.NewGuid(),
NomAffiche = "Durand Paul",
Email = "paul@exemple.fr",
Telephone = "0600000000",
PresenceConfirmee = false
});
return rendezVous;
}
private static RendezVous BuildNewSchemaRendezVous()
{
var rendezVous = new RendezVous
{
Titre = "RDV Omika",
Description = "Visite de contrôle annuelle",
Periode = new DateTimeRange(new DateTime(2026, 2, 12, 14, 0, 0), new DateTime(2026, 2, 12, 16, 0, 0)),
ClientId = Guid.NewGuid(),
SiteId = Guid.NewGuid(),
DossierId = Guid.NewGuid(),
InterventionId = Guid.NewGuid(),
TypeActivite = new ReferenceValue("Contrôle") { Key = "CONTROLE" },
Statut = new ReferenceValue("Confirmé") { Key = "CONFIRME" },
Couleur = "#00FF00",
EstTouteLaJournee = false,
EstArchive = false
};
rendezVous.AddCollaborateur(new AffectationCollaborateur
{
CollaborateurId = Guid.NewGuid(),
NomAffiche = "Dupont Jean",
Responsable = true,
Statut = new ReferenceValue("Validé") { Key = "VALIDE" },
TempsValide = true,
PeriodeParticuliere = new DateTimeRange(new DateTime(2026, 2, 12, 14, 30, 0), new DateTime(2026, 2, 12, 15, 30, 0))
});
// Pas de contact : le nouveau schéma gère RDV_Contact séparément.
return rendezVous;
}
[Fact]
public void SameDto_RepresentsOldAndNewSchema()
{
RendezVousDetailsDto oldDto = RendezVousContractMapper.ToDetails(BuildOldSchemaRendezVous());
RendezVousDetailsDto newDto = RendezVousContractMapper.ToDetails(BuildNewSchemaRendezVous());
// Le type du DTO est strictement le même pour les deux versions.
Assert.Equal(oldDto.GetType(), newDto.GetType());
// Les informations partagées sont portées de la même façon.
Assert.Equal("RDV Batik", oldDto.Titre);
Assert.Equal("RDV Omika", newDto.Titre);
Assert.Equal(new DateTime(2026, 2, 10, 9, 0, 0), oldDto.Debut);
Assert.Equal(new DateTime(2026, 2, 12, 16, 0, 0), newDto.Fin);
}
[Fact]
public void NewSchema_CanCarryLinkedEntities()
{
RendezVousDetailsDto newDto = RendezVousContractMapper.ToDetails(BuildNewSchemaRendezVous());
Assert.NotNull(newDto.ClientId);
Assert.NotNull(newDto.SiteId);
Assert.NotNull(newDto.DossierId);
Assert.NotNull(newDto.InterventionId);
}
[Fact]
public void OldSchema_AbsentInformationIsNull()
{
RendezVousDetailsDto oldDto = RendezVousContractMapper.ToDetails(BuildOldSchemaRendezVous());
// La description et les liens métier n'existaient pas dans l'ancien modèle.
Assert.Null(oldDto.Description);
Assert.Null(oldDto.ClientId);
Assert.Null(oldDto.DossierId);
Assert.Null(oldDto.InterventionId);
}
[Fact]
public void OldSchema_CarriesLegacyReferenceValues()
{
RendezVousDetailsDto oldDto = RendezVousContractMapper.ToDetails(BuildOldSchemaRendezVous());
Assert.NotNull(oldDto.TypeActivite);
Assert.Equal(3, oldDto.TypeActivite.LegacyId);
Assert.Equal(1, oldDto.Statut!.LegacyId);
// La nouvelle clé métier n'existe pas dans l'ancien schéma.
Assert.Null(oldDto.TypeActivite.Key);
}
[Fact]
public void NewSchema_CarriesBusinessKeys()
{
RendezVousDetailsDto newDto = RendezVousContractMapper.ToDetails(BuildNewSchemaRendezVous());
Assert.NotNull(newDto.TypeActivite);
Assert.Equal("CONTROLE", newDto.TypeActivite.Key);
Assert.Null(newDto.TypeActivite.LegacyId);
}
[Fact]
public void RendezVous_WithMultipleCollaborateurs_MapsAllOfThem()
{
RendezVousDetailsDto oldDto = RendezVousContractMapper.ToDetails(BuildOldSchemaRendezVous());
Assert.Equal(2, oldDto.Collaborateurs.Count);
Assert.True(oldDto.Collaborateurs[0].Responsable);
Assert.False(oldDto.Collaborateurs[1].Responsable);
}
[Fact]
public void RendezVous_WithoutContacts_IsSupported()
{
RendezVousDetailsDto newDto = RendezVousContractMapper.ToDetails(BuildNewSchemaRendezVous());
Assert.Empty(newDto.Contacts);
}
[Fact]
public void RendezVous_WithSingleCollaborateurCarriesTempsValide()
{
RendezVousDetailsDto newDto = RendezVousContractMapper.ToDetails(BuildNewSchemaRendezVous());
Assert.Single(newDto.Collaborateurs);
Assert.True(newDto.Collaborateurs[0].TempsValide);
Assert.NotNull(newDto.Collaborateurs[0].PeriodeDebut);
Assert.NotNull(newDto.Collaborateurs[0].PeriodeFin);
}
}
@@ -0,0 +1,69 @@
using Omika.Core.Domain.Entities.Planning;
using Omika.Core.Domain.ValueObjects;
namespace Omika.Tests.Domain;
/// <summary>
/// Tests de l'agrégat métier canonique <see cref="RendezVous"/>, unique pour
/// l'ancien et le nouveau schéma de base.
/// </summary>
public class RendezVousDomainTests
{
[Fact]
public void RendezVous_IsIndependentOfPhysicalTableNames()
{
var rendezVous = new RendezVous();
string[] forbidden = ["Planning_RDV", "RDV_Planning", "RDP_GUID", "PL_PKID"];
Assert.DoesNotContain(forbidden, name => rendezVous.GetType().Name.Contains(name, StringComparison.Ordinal));
foreach (var property in rendezVous.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, name => property.Name.Contains(name, StringComparison.Ordinal));
}
}
[Fact]
public void RendezVous_HoldsPeriodeAsValueObject()
{
var rendezVous = new RendezVous
{
Titre = "Visite",
Periode = new DateTimeRange(new DateTime(2026, 2, 1, 9, 0, 0), new DateTime(2026, 2, 1, 11, 0, 0))
};
Assert.Equal(new DateTime(2026, 2, 1, 9, 0, 0), rendezVous.Periode.Start);
Assert.Equal(new DateTime(2026, 2, 1, 11, 0, 0), rendezVous.Periode.End);
}
[Fact]
public void RendezVous_CanCarryCollaborateursAndContacts()
{
var rendezVous = new RendezVous();
rendezVous.AddCollaborateur(new AffectationCollaborateur { CollaborateurId = Guid.NewGuid(), Responsable = true });
rendezVous.AddCollaborateur(new AffectationCollaborateur { CollaborateurId = Guid.NewGuid() });
rendezVous.AddContact(new ParticipantRendezVous { ContactId = Guid.NewGuid(), PresenceConfirmee = true });
Assert.Equal(2, rendezVous.Collaborateurs.Count);
Assert.Single(rendezVous.Contacts);
Assert.True(rendezVous.Contacts[0].PresenceConfirmee);
}
[Fact]
public void RendezVous_CanCarryAnnulation()
{
var rendezVous = new RendezVous
{
Annulation = new AnnulationRendezVous
{
DateAnnulation = new DateTime(2026, 2, 1, 8, 0, 0),
Commentaire = "Reporté"
}
};
Assert.NotNull(rendezVous.Annulation);
Assert.Equal("Reporté", rendezVous.Annulation.Commentaire);
}
}
@@ -0,0 +1,73 @@
using Omika.Core.Services;
using Xunit;
namespace Omika.Tests.Domain;
public class RubriqueSubstitutionEngineTests
{
[Fact]
public void Substitute_ReplacesTokensCaseInsensitively()
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["NomClient"] = "ACME",
["DateIntervention"] = "2026-08-06"
};
string result = RubriqueSubstitutionEngine.Substitute(
"Bonjour $NOMCLIENT$, intervention du $DateIntervention$.",
values);
Assert.Equal("Bonjour ACME, intervention du 2026-08-06.", result);
}
[Fact]
public void Substitute_LeavesUnknownTokensUntouched()
{
var values = new Dictionary<string, string> { ["NomClient"] = "ACME" };
string result = RubriqueSubstitutionEngine.Substitute(
"Bonjour $NomClient$ — $Inconnu$",
values);
Assert.Equal("Bonjour ACME — $Inconnu$", result);
}
[Fact]
public void Substitute_NullOrEmptyTemplate_ReturnsEmpty()
{
Assert.Equal(string.Empty, RubriqueSubstitutionEngine.Substitute(null, new Dictionary<string, string>()));
Assert.Equal(string.Empty, RubriqueSubstitutionEngine.Substitute(string.Empty, new Dictionary<string, string>()));
}
[Fact]
public void Substitute_UnclosedTokenIsLeftUntouched()
{
string result = RubriqueSubstitutionEngine.Substitute(
"Bonjour $NomClient",
new Dictionary<string, string> { ["NomClient"] = "ACME" });
Assert.Equal("Bonjour $NomClient", result);
}
[Fact]
public void ExtractTokens_ReturnsOrderedUniqueTokens()
{
IReadOnlyList<string> tokens = RubriqueSubstitutionEngine.ExtractTokens(
"$A$ puis $B$ et encore $a$");
Assert.Equal(["A", "B"], tokens);
}
[Fact]
public void FindUnknownTokens_ReturnsTokensMissingFromValues()
{
var values = new Dictionary<string, string> { ["A"] = "1" };
IReadOnlyList<string> unknown = RubriqueSubstitutionEngine.FindUnknownTokens(
"$A$ $B$ $A$",
values);
Assert.Equal(["B"], unknown);
}
}
@@ -0,0 +1,340 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Interfaces.Mobile;
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.E2E;
/// <summary>
/// Test E2E automatisé du pipeline de synchronisation multi-connexion réel.
///
/// Ce test est <b>SKIPPABLE</b> : il n'écrit JAMAIS par défaut. Il exige
/// explicitement, via la configuration locale non versionnée
/// (<c>config/hfsql-test.local.json</c>), que les trois garde-fous soient
/// simultanément vrais :
/// <list type="bullet">
/// <item><c>E2E:AllowHfSqlWrites = true</c></item>
/// <item><c>E2E:ServiceDatabaseConfirmedTest = true</c></item>
/// <item><c>E2E:WebServiceDatabaseConfirmedTest = true</c></item>
/// </list>
/// Sinon, le test est SKIP (aucune écriture).
///
/// Le test valide le pipeline réel :
/// HTTP → auth (Bearer réellement validé par MobileAuthMiddleware) →
/// SyncOperationService → repository → TOSRV (base Service) →
/// Worker → MobReception (base WebService) → handler Intervention →
/// Business → MobLog → status API.
///
/// Limite documentée : ce test valide Bearer → middleware → endpoint Sync,
/// mais NE valide PAS Licence → activate → token (la vraie base Licence de test
/// n'est pas disponible ; BASE_LICENCES reste physiquement bloquée).
/// </summary>
public class SyncPipelineE2ETests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
// Délai d'attente raisonnable pour laisser le Worker externe traiter une
// opération jusqu'à son résultat terminal. Ce n'est PAS une correction
// d'idempotence : c'est un polling déterministe (pas un Task.Delay fixe
// utilisé comme preuve de succès).
private static readonly TimeSpan TerminalTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan PollingInterval = TimeSpan.FromMilliseconds(500);
private readonly WebApplicationFactory<Program> _factory;
public SyncPipelineE2ETests()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
// Charge la configuration locale HFSQL de test (non versionnée).
// Les garde-fous E2E y sont lus. Les variables d'environnement
// restent prioritaires.
var configPath = HfSqlTestConfigurationLoader.ResolveConfigPath();
if (configPath is not null && File.Exists(configPath))
{
config.AddJsonFile(configPath, optional: false, reloadOnChange: false);
}
// Clé de signature de TEST pour l'authentification mobile.
// Jamais en source : elle vient de la configuration locale.
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["MobileAuth:SigningKey"] = SigningKey
});
});
});
}
public void Dispose()
{
_factory.Dispose();
}
/// <summary>
/// Vérifie que les garde-fous E2E sont tous vrais avant toute écriture.
/// Sinon, le test est SKIP (aucune écriture).
/// </summary>
private static E2EOptions LoadE2EOptions()
{
var configPath = HfSqlTestConfigurationLoader.ResolveConfigPath();
if (configPath is null || !File.Exists(configPath))
{
return new E2EOptions();
}
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var options = new E2EOptions();
config.GetSection(E2EOptions.SectionName).Bind(options);
return options;
}
/// <summary>
/// Génère un token de test via le vrai TokenService (Bearer réellement
/// validé par MobileAuthMiddleware). Aucun token hardcodé en source.
/// </summary>
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "E2E_TEST",
DeviceId = "e2e-device-001",
UserId = "e2e-user-001",
CollaborateurId = "1",
NumeroLicence = "e2e-licence-test"
});
}
/// <summary>
/// Poll l'endpoint de statut jusqu'à un résultat terminal (SUCCESS ou
/// REJECTED) ou jusqu'au timeout. Retourne la réponse terminale, ou
/// <see langword="null"/> si le timeout est atteint sans résultat terminal.
///
/// Ce helper remplace tout <c>Task.Delay</c> fixe : il attend réellement le
/// résultat terminal du Worker externe, ce qui est la seule preuve valide
/// qu'une opération a été appliquée avant un éventuel replay.
/// </summary>
private async Task<SyncStatusResponse?> WaitForTerminalStatusAsync(
HttpClient client,
Guid operationId,
TimeSpan timeout,
TimeSpan pollingInterval,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var statusResponse = await client.GetAsync($"/api/sync/status/{operationId}", cancellationToken);
if (statusResponse.StatusCode == HttpStatusCode.OK)
{
var status = await statusResponse.Content.ReadFromJsonAsync<SyncStatusResponse>(cancellationToken: cancellationToken);
if (status is not null
&& (status.Status == SyncStatus.Success || status.Status == SyncStatus.Rejected))
{
return status;
}
}
await Task.Delay(pollingInterval, cancellationToken);
}
return null;
}
/// <summary>
/// Compte, en lecture seule, le nombre d'Interventions métier dont la
/// référence correspond exactement à <paramref name="reference"/>.
///
/// Utilise la connexion WebService/Business (base qui héberge les données
/// métier) via <see cref="IWebServiceHfSqlConnection"/>. Aucune écriture.
/// </summary>
private async Task<int> CountInterventionsAsync(
string reference,
CancellationToken cancellationToken = default)
{
// Le scope contient WebServiceHfSqlConnection qui n'implémente que
// IAsyncDisposable : on utilise une disposition asynchrone (CreateAsyncScope)
// pour éviter l'InvalidOperationException de Dispose() synchrone.
await using var scope = _factory.Services.CreateAsyncScope();
var connection = scope.ServiceProvider.GetRequiredService<IWebServiceHfSqlConnection>();
// La connexion doit être ouverte avant d'exécuter une commande.
await connection.OpenAsync(cancellationToken);
object? count = await connection.ExecuteScalarAsync(
"SELECT COUNT(*) FROM Intervention WHERE IN_RefInterne = ?",
[reference],
cancellationToken);
return Convert.ToInt32(count);
}
/// <summary>
/// Test E2E du pipeline complet : CREATE → TOSRV → MobReception → Business
/// → MobLog → status API.
///
/// SKIP si les garde-fous E2E ne sont pas tous vrais.
/// </summary>
[Fact]
public async Task Create_Intervention_Pipeline_IsGreen()
{
var e2e = LoadE2EOptions();
if (!e2e.IsWriteAllowed)
{
// SKIP : aucune écriture sans consentement explicite.
return;
}
// 1. Générer un token de test valide.
string token = CreateValidToken();
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
// 2. Construire une opération CREATE Intervention avec des données TEST.
var operationId = Guid.NewGuid();
var requestedEntityGuid = Guid.NewGuid();
string reference = $"E2E_TEST_{operationId.ToString("N")[..8]}";
var operation = new SyncOperation
{
OperationId = operationId,
DeviceId = "e2e-device-001",
EntityType = SyncEntityTypes.Intervention,
OperationType = SyncOperationType.Create,
EntityGuid = requestedEntityGuid,
Payload = JsonSerializer.Serialize(new
{
Reference = reference,
Titre = "Intervention E2E TEST",
Description = "Données de test E2E - aucune donnée personnelle réelle."
})
};
// 3. Envoyer via POST /api/sync/operations.
var response = await client.PostAsJsonAsync("/api/sync/operations", operation);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var receipt = await response.Content.ReadFromJsonAsync<SyncOperationReceipt>();
Assert.NotNull(receipt);
Assert.Equal("RECEIVED", receipt.Status);
Assert.Equal(operationId, receipt.OperationId);
// 4. Poller le statut jusqu'au résultat terminal SUCCESS (le Worker
// externe doit avoir traité l'opération). On exige réellement SUCCESS,
// pas un simple GET immédiat qui pourrait être encore Pending.
SyncStatusResponse? status = await WaitForTerminalStatusAsync(
client, operationId, TerminalTimeout, PollingInterval);
Assert.NotNull(status);
Assert.Equal(operationId, status!.OperationId);
Assert.Equal(SyncStatus.Success, status.Status);
// 5. Vérifier qu'exactement UNE Intervention métier a été créée.
int count = await CountInterventionsAsync(reference);
Assert.Equal(1, count);
}
/// <summary>
/// Test E2E du replay : réenvoyer exactement le même OperationId, le même
/// RequestedEntityGuid et le même payload APRÈS que le premier POST a atteint
/// SUCCESS. Attendu : aucune deuxième Intervention métier ; résultat terminal
/// réutilisé ; même CanonicalEntityGuid ; status toujours SUCCESS.
///
/// Ce test est AUTONOME : il génère son propre OperationId et ne dépend
/// d'aucun autre test xUnit (pas d'ordre d'exécution implicite).
///
/// SKIP si les garde-fous E2E ne sont pas tous vrais.
/// </summary>
[Fact]
public async Task Replay_SameOperationId_IsIdempotent()
{
var e2e = LoadE2EOptions();
if (!e2e.IsWriteAllowed)
{
// SKIP : aucune écriture sans consentement explicite.
return;
}
string token = CreateValidToken();
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
// 1. Générer un scénario autonome : OperationId X, RequestedEntityGuid X,
// payload P (référence dérivée de X). Préfixe E2E_TX_ pour distinguer
// cette validation des anciennes lignes E2E_TEST_* (traces historiques).
var operationId = Guid.NewGuid();
var requestedEntityGuid = Guid.NewGuid();
string reference = $"E2E_TX_{operationId.ToString("N")[..8]}";
var operation = new SyncOperation
{
OperationId = operationId,
DeviceId = "e2e-device-001",
EntityType = SyncEntityTypes.Intervention,
OperationType = SyncOperationType.Create,
EntityGuid = requestedEntityGuid,
Payload = JsonSerializer.Serialize(new
{
Reference = reference,
Titre = "Intervention E2E TEST (replay)",
Description = "Données de test E2E - aucune donnée personnelle réelle."
})
};
// 2. Premier POST : CREATE X/P.
var first = await client.PostAsJsonAsync("/api/sync/operations", operation);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
// 3. Attendre réellement le SUCCESS terminal du premier POST avant tout
// replay. Sinon, ce ne serait pas un test d'idempotence terminale.
SyncStatusResponse? firstStatus = await WaitForTerminalStatusAsync(
client, operationId, TerminalTimeout, PollingInterval);
Assert.NotNull(firstStatus);
Assert.Equal(SyncStatus.Success, firstStatus!.Status);
// 4. Compter les Interventions métier : exactement 1 après le premier POST.
int countAfterFirst = await CountInterventionsAsync(reference);
Assert.Equal(1, countAfterFirst);
// 5. Re-POST EXACTEMENT le même X/P (même OperationId, même
// RequestedEntityGuid, même payload).
var second = await client.PostAsJsonAsync("/api/sync/operations", operation);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var receipt = await second.Content.ReadFromJsonAsync<SyncOperationReceipt>();
Assert.NotNull(receipt);
Assert.Equal("RECEIVED", receipt.Status);
Assert.Equal(operationId, receipt.OperationId);
// 6. Attendre le résultat terminal du replay : SUCCESS, avec le même
// CanonicalEntityGuid que le premier POST (résultat réutilisé, pas
// réappliqué).
SyncStatusResponse? replayStatus = await WaitForTerminalStatusAsync(
client, operationId, TerminalTimeout, PollingInterval);
Assert.NotNull(replayStatus);
Assert.Equal(SyncStatus.Success, replayStatus!.Status);
Assert.Equal(firstStatus.CanonicalEntityGuid, replayStatus.CanonicalEntityGuid);
// 7. Vérifier que le nombre d'Interventions métier est TOUJOURS 1 :
// le replay n'a pas créé de doublon.
int countAfterReplay = await CountInterventionsAsync(reference);
Assert.Equal(1, countAfterReplay);
}
}
@@ -0,0 +1,253 @@
using System.Security.Cryptography;
using System.Text;
using Omika.Data.HfSql.Protection;
namespace Omika.Tests.HfSql;
/// <summary>
/// Unit tests for <see cref="HfsqlFilePasswordDeriver"/>.
/// All test values are synthetic — no real historical secrets
/// or derived production passwords are present.
/// </summary>
public sealed class HfsqlFilePasswordDeriverTests
{
// Synthetic test values — never real production secrets.
private const string FakeSecret = "TestSecret_2026";
private const string TestDbName = "MY_TEST_DB";
private static readonly IHfsqlFilePasswordDeriver Deriver = new HfsqlFilePasswordDeriver();
// ================================================================
// ProtectionStd — validation
// ================================================================
[Fact]
public void ProtectionStd_NullDatabaseName_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => Deriver.DeriveProtectionStd(null!));
Assert.Contains("Database name", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProtectionStd_EmptyDatabaseName_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => Deriver.DeriveProtectionStd(""));
Assert.Contains("Database name", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProtectionStd_WhitespaceDatabaseName_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => Deriver.DeriveProtectionStd(" "));
Assert.Contains("Database name", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// ================================================================
// ProtectionStd — output characteristics
// ================================================================
[Fact]
public void ProtectionStd_ResultIs32Chars()
{
var result = Deriver.DeriveProtectionStd(TestDbName);
Assert.Equal(32, result.Length);
}
[Fact]
public void ProtectionStd_ResultIsHexOnly()
{
var result = Deriver.DeriveProtectionStd(TestDbName);
Assert.Matches("^[0-9A-F]{32}$", result);
}
[Fact]
public void ProtectionStd_ResultIsDeterministic()
{
var a = Deriver.DeriveProtectionStd(TestDbName);
var b = Deriver.DeriveProtectionStd(TestDbName);
Assert.Equal(a, b);
}
[Fact]
public void ProtectionStd_DifferentDatabaseNames_DifferentResults()
{
var r1 = Deriver.DeriveProtectionStd("DB_ONE");
var r2 = Deriver.DeriveProtectionStd("DB_TWO");
Assert.NotEqual(r1, r2);
}
[Fact]
public void ProtectionStd_CaseIsPreserved()
{
var upper = Deriver.DeriveProtectionStd("MYDB");
var lower = Deriver.DeriveProtectionStd("mydb");
Assert.NotEqual(upper, lower);
}
[Fact]
public void ProtectionStd_UnderscoreIsPreserved()
{
var with = Deriver.DeriveProtectionStd("MY_DB");
var without = Deriver.DeriveProtectionStd("MYDB");
Assert.NotEqual(with, without);
}
[Fact]
public void ProtectionStd_NoTrimmingApplied()
{
var trimmed = Deriver.DeriveProtectionStd("MYDB");
var padded = Deriver.DeriveProtectionStd(" MYDB ");
Assert.NotEqual(trimmed, padded);
}
[Fact]
public void ProtectionStd_MatchesManualMd5()
{
// MD5 of TestDbName using Windows-1252 → hex
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var ansi = Encoding.GetEncoding(1252);
byte[] expectedHash = MD5.HashData(ansi.GetBytes(TestDbName));
string expected = Convert.ToHexString(expectedHash);
Assert.Equal(expected, Deriver.DeriveProtectionStd(TestDbName));
}
// ================================================================
// ProtectionPlus — validation
// ================================================================
[Fact]
public void ProtectionPlus_NullDatabaseName_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Deriver.DeriveProtectionPlus(null!, FakeSecret));
Assert.Contains("Database name", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProtectionPlus_EmptySecret_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Deriver.DeriveProtectionPlus(TestDbName, ReadOnlySpan<char>.Empty));
Assert.Contains("Historical secret", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// ================================================================
// ProtectionPlus — output characteristics
// ================================================================
[Fact]
public void ProtectionPlus_ResultIs32Chars()
{
var result = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
Assert.Equal(32, result.Length);
}
[Fact]
public void ProtectionPlus_ResultIsHexOnly()
{
var result = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
Assert.Matches("^[0-9A-F]{32}$", result);
}
[Fact]
public void ProtectionPlus_ResultIsDeterministic()
{
var a = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
var b = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
Assert.Equal(a, b);
}
[Fact]
public void ProtectionPlus_DiffersFromProtectionStd()
{
var std = Deriver.DeriveProtectionStd(TestDbName);
var plus = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
Assert.NotEqual(std, plus);
}
[Fact]
public void ProtectionPlus_DifferentSecrets_DifferentResults()
{
var r1 = Deriver.DeriveProtectionPlus(TestDbName, "SecretA");
var r2 = Deriver.DeriveProtectionPlus(TestDbName, "SecretB");
Assert.NotEqual(r1, r2);
}
// ================================================================
// ProtectionPlus — concatenation ORDER
// ================================================================
[Fact]
public void ProtectionPlus_SecretFirst_NotDatabaseFirst()
{
// Historical order: secret + databaseName
var actual = Deriver.DeriveProtectionPlus(TestDbName, FakeSecret);
// If the order were reversed (databaseName + secret),
// the hash would be different.
var reversed = Deriver.ComputeMd5ForTest(TestDbName + FakeSecret);
Assert.NotEqual(reversed, actual);
}
[Fact]
public void ProtectionPlus_MatchesManualSecretFirstMd5()
{
// Verify: MD5(FakeSecret + TestDbName) with Windows-1252
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var ansi = Encoding.GetEncoding(1252);
byte[] expectedHash = MD5.HashData(ansi.GetBytes(FakeSecret + TestDbName));
string expected = Convert.ToHexString(expectedHash);
Assert.Equal(expected, Deriver.DeriveProtectionPlus(TestDbName, FakeSecret));
}
// ================================================================
// Encoding: non-ASCII character
// ================================================================
[Fact]
public void NonAsciiDatabaseName_UsesLegacyAnsiEncoding()
{
// Database name with a non-ASCII character (É = U+00C9).
// In Windows-1252, É is 0xC9 (1 byte).
// In UTF-8, É is 0xC3 0x89 (2 bytes).
// The two encodings produce different MD5 hashes.
const string dbName = "BASE_É_TEST";
var result = Deriver.DeriveProtectionStd(dbName);
// Compute the expected value using Windows-1252.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var ansi = Encoding.GetEncoding(1252);
byte[] expectedHash = MD5.HashData(ansi.GetBytes(dbName));
string expectedWindows1252 = Convert.ToHexString(expectedHash);
// Compute using UTF-8 for comparison.
byte[] utf8Hash = MD5.HashData(Encoding.UTF8.GetBytes(dbName));
string expectedUtf8 = Convert.ToHexString(utf8Hash);
Assert.Equal(expectedWindows1252, result);
Assert.NotEqual(expectedUtf8, result); // proves encoding matters
}
}
// ================================================================
// Test helper extension (only for verifying concatenation order)
// ================================================================
/// <summary>
/// Exposes the internal MD5 computation for test verification only.
/// NOT part of the production API.
/// </summary>
file static class HfsqlFilePasswordDeriverTestHelper
{
public static string ComputeMd5ForTest(this IHfsqlFilePasswordDeriver _, string input)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var ansi = Encoding.GetEncoding(1252);
byte[] hash = MD5.HashData(ansi.GetBytes(input));
return Convert.ToHexString(hash);
}
}
@@ -0,0 +1,84 @@
using Omika.Core.Domain.Entities.Clients;
using Omika.Data.HfSql.Mapping;
using Omika.Data.Mapping.Batik;
using BatikClientRow = Omika.Data.HfSql.Models.Batik.ClientRow;
namespace Omika.Tests.HfSql.Mapping;
/// <summary>
/// Test de bout en bout du pipeline de lecture Client sans base réelle :
/// résultat tabulaire simulé (dictionnaire colonne → valeur, comme le
/// retournerait <c>IHfsqlConnection.QueryAsync</c>) → <see cref="HfsqlRowMapper"/>
/// → <see cref="BatikClientMapper"/> → <see cref="Client"/> canonique.
///
/// Aucun ODBC, aucun HFSQL, aucune base : uniquement des objets en mémoire.
/// </summary>
public class HfsqlRowMapperPipelineTests
{
[Fact]
public void Pipeline_SimulatedSqlResult_ToCanonicalClient()
{
// 1. Résultat tabulaire simulé (ce que QueryAsync retournerait).
var sqlRow = new Dictionary<string, object?>
{
["CL_PKID"] = 42L,
["CL_Nom"] = "Ville de Lyon",
["CL_CodeClient"] = "C001",
["CL_CodeTVA"] = "FR123456789",
["CL_SIREN"] = "123456789",
["CL_SIRET"] = "12345678900012",
["CL_Blocage"] = true,
["CL_RaisonBlocage"] = "Litige en cours",
["Archive"] = false,
["CL_Email"] = "contact@lyon.fr",
["CL_Tel"] = "0472000000"
};
// 2. Résultat tabulaire → Row physique Batik.
BatikClientRow row = HfsqlRowMapper.Map<BatikClientRow>(sqlRow);
Assert.Equal(42L, row.CLPKID);
Assert.Equal("Ville de Lyon", row.CLNom);
Assert.Equal("C001", row.CLCodeClient);
Assert.True(row.CLBlocage);
// 3. Row physique → Client canonique (mapper Batik existant).
var result = BatikClientMapper.Map(row);
Client client = result.Value;
Assert.Equal("C001", client.Code);
Assert.Equal("Ville de Lyon", client.Nom);
Assert.Equal("FR123456789", client.CodeTva);
Assert.Equal("123456789", client.Siren);
Assert.Equal("12345678900012", client.Siret);
Assert.True(client.EstBloque);
Assert.Equal("Litige en cours", client.RaisonBlocage);
Assert.False(client.EstArchive);
Assert.NotNull(client.Coordonnees);
Assert.Equal("contact@lyon.fr", client.Coordonnees!.Email);
Assert.Equal("0472000000", client.Coordonnees!.Phone);
}
[Fact]
public void Pipeline_SimulatedSqlResult_WithMissingColumns_StillMaps()
{
// Un SELECT ciblé (liste) ne ramène que quelques colonnes.
var sqlRow = new Dictionary<string, object?>
{
["CL_PKID"] = 7L,
["CL_Nom"] = "Mairie de Paris"
};
BatikClientRow row = HfsqlRowMapper.Map<BatikClientRow>(sqlRow);
var result = BatikClientMapper.Map(row);
Client client = result.Value;
Assert.Equal(7L, row.CLPKID);
Assert.Equal("Mairie de Paris", client.Nom);
Assert.Null(client.Code);
Assert.Null(client.Coordonnees);
Assert.False(client.EstBloque);
}
}
@@ -0,0 +1,550 @@
using Omika.Data.HfSql.Mapping;
using BatikClientRow = Omika.Data.HfSql.Models.Batik.ClientRow;
using OmikaClientRow = Omika.Data.HfSql.Models.Omika.ClientRow;
using BatikAdresseRow = Omika.Data.HfSql.Models.Batik.AdresseRow;
using BatikPersonneRow = Omika.Data.HfSql.Models.Batik.PersonneRow;
using BatikLieuRow = Omika.Data.HfSql.Models.Batik.LieuRow;
using BatikParametreMetierRow = Omika.Data.HfSql.Models.Batik.ParametreMetierRow;
namespace Omika.Tests.HfSql.Mapping;
/// <summary>
/// Tests unitaires de <see cref="HfsqlRowMapper"/> : conversion d'un résultat
/// tabulaire physique (dictionnaire colonne → valeur) vers un modèle Row
/// physique généré. Aucune base réelle, aucun HFSQL, aucun ODBC.
/// </summary>
public class HfsqlRowMapperTests
{
// ---- 1. ClientRow Batik minimal ------------------------------------------------
[Fact]
public void Map_BatikClientRow_Minimal_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 42L,
["CL_Nom"] = "Ville de Lyon",
["CL_CodeClient"] = "C001",
["CL_Blocage"] = true,
["Archive"] = false
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(42L, row.CLPKID);
Assert.Equal("Ville de Lyon", row.CLNom);
Assert.Equal("C001", row.CLCodeClient);
Assert.True(row.CLBlocage);
Assert.False(row.Archive);
}
// ---- 2. ClientRow Omika minimal ------------------------------------------------
[Fact]
public void Map_OmikaClientRow_Minimal_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 7L,
["CL_Nom"] = "Mairie de Paris",
["CL_CleType"] = "COLLECTIVITE",
["CL_CleStatut"] = "ACTIF",
["GUID"] = "abc-123"
};
var row = HfsqlRowMapper.Map<OmikaClientRow>(values);
Assert.Equal(7L, row.CLPKID);
Assert.Equal("Mairie de Paris", row.CLNom);
Assert.Equal("COLLECTIVITE", row.CLCleType);
Assert.Equal("ACTIF", row.CLCleStatut);
Assert.Equal("abc-123", row.GUID);
}
// ---- 3. Colonne numérique long -------------------------------------------------
[Fact]
public void Map_LongColumn_MapsValue()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 123456789L,
["CL_IDType"] = 5L
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(123456789L, row.CLPKID);
Assert.Equal(5L, row.CLIDType);
}
// ---- 4. String -----------------------------------------------------------------
[Fact]
public void Map_StringColumn_MapsValue()
{
var values = new Dictionary<string, object?>
{
["CL_Nom"] = "Entreprise Dupont",
["CL_Email"] = "contact@dupont.fr"
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal("Entreprise Dupont", row.CLNom);
Assert.Equal("contact@dupont.fr", row.CLEmail);
}
// ---- 5. Bool natif -------------------------------------------------------------
[Fact]
public void Map_BoolNative_MapsValue()
{
var values = new Dictionary<string, object?>
{
["CL_Blocage"] = true,
["Archive"] = false
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.True(row.CLBlocage);
Assert.False(row.Archive);
}
// ---- 6. Bool depuis 0 ----------------------------------------------------------
[Fact]
public void Map_BoolFromZero_IsFalse()
{
var values = new Dictionary<string, object?>
{
["CL_Blocage"] = 0L
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.False(row.CLBlocage);
}
// ---- 7. Bool depuis 1 ----------------------------------------------------------
[Fact]
public void Map_BoolFromOne_IsTrue()
{
var values = new Dictionary<string, object?>
{
["CL_Blocage"] = 1L
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.True(row.CLBlocage);
}
// ---- 8. Null vers propriété nullable/référence --------------------------------
[Fact]
public void Map_NullToNullable_IsNull()
{
var values = new Dictionary<string, object?>
{
["CL_Nom"] = null,
["CL_Email"] = null
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Null(row.CLNom);
Assert.Null(row.CLEmail);
}
// ---- 9. Null vers valeur non nullable => défaut -------------------------------
[Fact]
public void Map_NullToNonNullable_KeepsDefault()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = null,
["CL_Blocage"] = null
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(0L, row.CLPKID);
Assert.False(row.CLBlocage);
}
// ---- 10. Colonne absente => propriété par défaut ------------------------------
[Fact]
public void Map_MissingColumn_KeepsDefault()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 1L
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(1L, row.CLPKID);
Assert.Null(row.CLNom);
Assert.Null(row.CLCodeClient);
Assert.False(row.CLBlocage);
}
// ---- 11. Mapping insensible à la casse ----------------------------------------
[Fact]
public void Map_ColumnNameCaseInsensitive()
{
var values = new Dictionary<string, object?>
{
["cl_pkid"] = 9L,
["CL_NOM"] = "Casé",
["Cl_Blocage"] = true
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(9L, row.CLPKID);
Assert.Equal("Casé", row.CLNom);
Assert.True(row.CLBlocage);
}
// ---- 12. Colonnes supplémentaires inconnues => ignorées -----------------------
[Fact]
public void Map_UnknownColumns_AreIgnored()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 1L,
["COLONNE_INCONNUE"] = "valeur",
["AUTRE"] = 123
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(1L, row.CLPKID);
}
// ---- 13. Valeur numérique convertible vers le type cible ----------------------
[Fact]
public void Map_NumericConvertible_ConvertsToTargetType()
{
// CL_PKID est long : une valeur int doit être convertie.
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 42,
["CL_IDType"] = 3
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(42L, row.CLPKID);
Assert.Equal(3L, row.CLIDType);
}
// ---- 14. Type incompatible => exception explicite -----------------------------
[Fact]
public void Map_IncompatibleType_ThrowsExplicitException()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = "pas-un-nombre"
};
var ex = Assert.Throws<InvalidOperationException>(
() => HfsqlRowMapper.Map<BatikClientRow>(values));
Assert.Contains("CL_PKID", ex.Message);
Assert.Contains("ClientRow", ex.Message);
Assert.Contains("CLPKID", ex.Message);
}
// ---- 15. Aucun contenu sensible de valeur dans l'exception --------------------
[Fact]
public void Map_Exception_DoesNotContainSensitiveValue()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = "mot-de-passe-secret"
};
var ex = Assert.Throws<InvalidOperationException>(
() => HfsqlRowMapper.Map<BatikClientRow>(values));
Assert.DoesNotContain("mot-de-passe-secret", ex.Message);
}
// ---- 16. Row avec champs HfSqlLegacyField => mappés normalement ---------------
[Fact]
public void Map_LegacyFields_AreMappedNormally()
{
// Omika.ClientRow a des champs zombie/legacy (CL_IDType, CL_IDStatut).
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 5L,
["CL_IDType"] = 2L,
["CL_IDStatut"] = 1L
};
var row = HfsqlRowMapper.Map<OmikaClientRow>(values);
Assert.Equal(5L, row.CLPKID);
Assert.Equal(2L, row.CLIDType);
Assert.Equal(1L, row.CLIDStatut);
}
// ---- 17. AdresseRow ------------------------------------------------------------
[Fact]
public void Map_AdresseRow_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["AD_PKID"] = 10L,
["AD_Etiquette"] = "Mairie",
["AD_Ligne_1"] = "1 place de la République",
["AD_CodePostal"] = "69001",
["AD_Ville"] = "Lyon"
};
var row = HfsqlRowMapper.Map<BatikAdresseRow>(values);
Assert.Equal(10L, row.ADPKID);
Assert.Equal("Mairie", row.ADEtiquette);
Assert.Equal("1 place de la République", row.ADLigne1);
Assert.Equal("69001", row.ADCodePostal);
Assert.Equal("Lyon", row.ADVille);
}
// ---- 18. PersonneRow -----------------------------------------------------------
[Fact]
public void Map_PersonneRow_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["PE_PKID"] = 20L,
["PE_Nom"] = "Dupont",
["PE_Prenom"] = "Jean",
["PE_Email"] = "jean.dupont@mail.fr",
["PE_Actif"] = true
};
var row = HfsqlRowMapper.Map<BatikPersonneRow>(values);
Assert.Equal(20L, row.PEPKID);
Assert.Equal("Dupont", row.PENom);
Assert.Equal("Jean", row.PEPrenom);
Assert.Equal("jean.dupont@mail.fr", row.PEEmail);
Assert.True(row.PEActif);
}
// ---- 19. LieuRow ---------------------------------------------------------------
[Fact]
public void Map_LieuRow_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["LI_PKID"] = 30L,
["LI_CodeLieu"] = "SITE-01",
["LI_IDAdresse"] = 10L,
["LI_IDClientPrincipal"] = 42L,
["LI_Blocage"] = false
};
var row = HfsqlRowMapper.Map<BatikLieuRow>(values);
Assert.Equal(30L, row.LIPKID);
Assert.Equal("SITE-01", row.LICodeLieu);
Assert.Equal(10L, row.LIIDAdresse);
Assert.Equal(42L, row.LIIDClientPrincipal);
Assert.False(row.LIBlocage);
}
// ---- 20. ParametreMetierRow ----------------------------------------------------
[Fact]
public void Map_ParametreMetierRow_MapsCorrectly()
{
var values = new Dictionary<string, object?>
{
["PA_PKID"] = 100L,
["PA_Code"] = "TYP",
["PA_Libelle"] = "Type de client",
["PA_Valeur"] = "01",
["PA_Ordre"] = 3,
["PA_Actif"] = true
};
var row = HfsqlRowMapper.Map<BatikParametreMetierRow>(values);
Assert.Equal(100L, row.PAPKID);
Assert.Equal("TYP", row.PACode);
Assert.Equal("Type de client", row.PALibelle);
Assert.Equal("01", row.PAValeur);
Assert.Equal(3, row.PAOrdre);
Assert.True(row.PAActif);
}
// ---- 21. Plusieurs appels successifs produisent le même résultat --------------
[Fact]
public void Map_RepeatedCalls_AreDeterministic()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 42L,
["CL_Nom"] = "Ville de Lyon"
};
var first = HfsqlRowMapper.Map<BatikClientRow>(values);
var second = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(first.CLPKID, second.CLPKID);
Assert.Equal(first.CLNom, second.CLNom);
}
// ---- 22. Utilisation concurrente sans corruption ------------------------------
[Fact]
public void Map_ConcurrentCalls_AreSafe()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 42L,
["CL_Nom"] = "Ville de Lyon"
};
var results = new BatikClientRow[100];
Parallel.For(0, results.Length, i =>
{
results[i] = HfsqlRowMapper.Map<BatikClientRow>(values);
});
foreach (var row in results)
{
Assert.Equal(42L, row.CLPKID);
Assert.Equal("Ville de Lyon", row.CLNom);
}
}
// ---- 23. Aucune dépendance à ODBC dans l'API publique -------------------------
[Fact]
public void Map_PublicSignature_DoesNotExposeOdbc()
{
var method = typeof(HfsqlRowMapper).GetMethod(nameof(HfsqlRowMapper.Map))!;
Assert.DoesNotContain("Odbc", method.ReturnType.FullName, StringComparison.Ordinal);
foreach (var parameter in method.GetParameters())
{
Assert.DoesNotContain("Odbc", parameter.ParameterType.FullName, StringComparison.Ordinal);
}
}
// ---- 24. Aucun type canonique Client/Adresse/Contact/Site dans l'API publique --
[Fact]
public void Map_PublicSignature_DoesNotExposeCanonicalTypes()
{
var method = typeof(HfsqlRowMapper).GetMethod(nameof(HfsqlRowMapper.Map))!;
Assert.DoesNotContain("Client", method.ReturnType.FullName, StringComparison.Ordinal);
Assert.DoesNotContain("Adresse", method.ReturnType.FullName, StringComparison.Ordinal);
Assert.DoesNotContain("Contact", method.ReturnType.FullName, StringComparison.Ordinal);
Assert.DoesNotContain("Site", method.ReturnType.FullName, StringComparison.Ordinal);
}
// ---- 25. DBNull.Value défensif -------------------------------------------------
[Fact]
public void Map_DbNull_IsTreatedAsNull()
{
var values = new Dictionary<string, object?>
{
["CL_Nom"] = DBNull.Value,
["CL_PKID"] = DBNull.Value
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Null(row.CLNom);
Assert.Equal(0L, row.CLPKID);
}
// ---- 26. DateTime --------------------------------------------------------------
[Fact]
public void Map_DateTimeColumn_MapsValue()
{
var date = new DateTime(2024, 5, 1, 10, 30, 0);
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 1L,
["CreationDate"] = date
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(date, row.CreationDate);
}
// ---- 27. Valeur nulle vers propriété valeur non nullable ----------------------
[Fact]
public void Map_NullToValueType_KeepsDefault()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = null,
["CL_IDType"] = null
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(0L, row.CLPKID);
Assert.Equal(0L, row.CLIDType);
}
// ---- 28. Chaîne NUL-paddée (rubrique « Mémo texte » / largeur fixe) ------------
[Fact]
public void Map_StringWithNulPadding_IsNormalized()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 1L,
["CL_Nom"] = "Ville de Lyon\u0000\u0000\u0000",
["CL_CodeClient"] = "C001\u0000\u0000"
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal("Ville de Lyon", row.CLNom);
Assert.Equal("C001", row.CLCodeClient);
}
[Fact]
public void Map_StringAllNul_IsNormalizedToEmpty()
{
var values = new Dictionary<string, object?>
{
["CL_PKID"] = 1L,
["CL_Nom"] = "\u0000\u0000\u0000"
};
var row = HfsqlRowMapper.Map<BatikClientRow>(values);
Assert.Equal(string.Empty, row.CLNom);
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Reflection;
using Omika.Data.HfSql.Metadata;
namespace Omika.Tests;
/// <summary>
/// Tests de cohérence des modèles physiques HFSQL générés à partir du
/// dictionnaire de compatibilité Batik / Omika (docs/schema-compatibilite-batik-omika.json).
///
/// Ces tests valident que les classes générées portent bien leurs attributs
/// de mapping : nom de table, nom de fichier disque, clé primaire, rubriques
/// HFSQL physiques et marquage des rubriques « zombie ».
/// </summary>
public class HfSqlModelTests
{
[Theory]
[InlineData("Omika.Data.HfSql.Models.Omika.DocumentRow")]
[InlineData("Omika.Data.HfSql.Models.Omika.InterventionRow")]
[InlineData("Omika.Data.HfSql.Models.Omika.RDVPlanningRow")]
[InlineData("Omika.Data.HfSql.Models.Batik.DocumentRow")]
[InlineData("Omika.Data.HfSql.Models.Batik.InterventionRow")]
public void Model_HasHfSqlTableAttribute(string typeName)
{
Type? type = Type.GetType(typeName + ", Omika.Data");
Assert.NotNull(type);
HfSqlTableAttribute? attr = type!.GetCustomAttribute<HfSqlTableAttribute>();
Assert.NotNull(attr);
Assert.False(string.IsNullOrWhiteSpace(attr!.TableName));
}
[Fact]
public void OmikaDocumentRow_HasExpectedMapping()
{
Type type = typeof(Omika.Data.HfSql.Models.Omika.DocumentRow);
HfSqlTableAttribute? table = type.GetCustomAttribute<HfSqlTableAttribute>();
Assert.NotNull(table);
Assert.Equal("Document", table!.TableName);
Assert.Equal("Document.fic", table.DiskName);
Assert.Contains("ProtectionStd", table.Groups);
Assert.Contains("Omika", table.SchemaVersions);
PropertyInfo pk = type.GetProperty(nameof(Omika.Data.HfSql.Models.Omika.DocumentRow.DCPKID))
?? throw new InvalidOperationException("Propriété DCPKID introuvable.");
Assert.NotNull(pk.GetCustomAttribute<HfSqlPrimaryKeyAttribute>());
PropertyInfo zombie = type.GetProperty(nameof(Omika.Data.HfSql.Models.Omika.DocumentRow.DCCategorie))
?? throw new InvalidOperationException("Propriété DCCategorie introuvable.");
HfSqlColumnAttribute? column = zombie.GetCustomAttribute<HfSqlColumnAttribute>();
Assert.NotNull(column);
Assert.Equal("DC_Categorie", column!.ColumnName);
Assert.True(column.IsZombie);
Assert.NotNull(zombie.GetCustomAttribute<HfSqlLegacyFieldAttribute>());
}
[Fact]
public void OmikaDocumentRow_HasOmikaOnlyColumns()
{
Type type = typeof(Omika.Data.HfSql.Models.Omika.DocumentRow);
PropertyInfo cleCategorie = type.GetProperty(nameof(Omika.Data.HfSql.Models.Omika.DocumentRow.DCCleCategorie))
?? throw new InvalidOperationException("Propriété DCCleCategorie introuvable.");
HfSqlColumnAttribute? column = cleCategorie.GetCustomAttribute<HfSqlColumnAttribute>();
Assert.NotNull(column);
Assert.True(column!.IsOmikaOnly);
}
[Fact]
public void BatikInterventionCollaborateur_ExistsOnlyInBatik()
{
// La table Intervention_Collaborateur n'existe que dans l'ancienne base.
Type? batik = Type.GetType("Omika.Data.HfSql.Models.Batik.InterventionCollaborateurRow, Omika.Data");
Type? omika = Type.GetType("Omika.Data.HfSql.Models.Omika.InterventionCollaborateurRow, Omika.Data");
Assert.NotNull(batik);
Assert.Null(omika);
HfSqlTableAttribute? attr = batik!.GetCustomAttribute<HfSqlTableAttribute>();
Assert.NotNull(attr);
Assert.Equal(["Batik"], attr!.SchemaVersions);
}
[Fact]
public void OmikaOnlyTables_HaveNoBatikCounterpart()
{
string[] omikaOnly = ["DocAide", "CategorieAcces", "ElementModifie", "ElementSupprime",
"Etats", "ModeleRapport", "RDVCollaborateur", "RDVContact", "RDVPlanning", "Role", "RubriquesDocument"];
foreach (string table in omikaOnly)
{
string typeName = "Omika.Data.HfSql.Models.Omika." + table + "Row, Omika.Data";
Type? omikaType = Type.GetType(typeName);
Assert.NotNull(omikaType);
}
}
}
@@ -0,0 +1,70 @@
using Omika.Data.Configuration;
namespace Omika.Tests;
public class HfsqlOptionsValidatorTests
{
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var options = HfsqlOptionsValidator.CreateDefault();
options.Server = "hfsql-serveur";
options.DatabaseName = "Omika";
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = HfsqlOptionsValidator.CreateDefault();
options.Server = null;
options.DatabaseName = "Omika";
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabaseName_ReturnsFailure()
{
var options = HfsqlOptionsValidator.CreateDefault();
options.Server = "hfsql-serveur";
options.DatabaseName = null;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
public void Validate_InvalidPort_ReturnsFailure(int port)
{
var options = HfsqlOptionsValidator.CreateDefault();
options.Server = "hfsql-serveur";
options.DatabaseName = "Omika";
options.Port = port;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CreateDefault_UsesLegacyConnectionName()
{
var options = HfsqlOptionsValidator.CreateDefault();
Assert.Equal(Omika.Core.Constants.DatabaseConstants.LegacyConnectionName, options.ConnectionName);
Assert.Equal(Omika.Core.Constants.DatabaseConstants.DefaultHfsqlServerPort, options.Port);
}
}
+50
View File
@@ -0,0 +1,50 @@
using Omika.Data.Mapping;
namespace Omika.Tests;
public class LegacyTableMapTests
{
[Fact]
public void OmikaTables_ContainsAllFiftyEightTables()
{
Assert.Equal(58, LegacyTableMap.OmikaTables.Count);
}
[Theory]
[InlineData("Client")]
[InlineData("Dossier")]
[InlineData("Intervention")]
[InlineData("Planning_RDV")]
[InlineData("RDV_Planning")]
[InlineData("Utilisateur")]
public void IsOmikaTable_RecognizesKnownTables(string tableName)
{
Assert.True(LegacyTableMap.IsOmikaTable(tableName));
}
[Fact]
public void IsOmikaTable_IsCaseInsensitive()
{
Assert.True(LegacyTableMap.IsOmikaTable("client"));
Assert.True(LegacyTableMap.IsOmikaTable("CLIENT"));
}
[Fact]
public void IsOmikaTable_UnknownTable_ReturnsFalse()
{
Assert.False(LegacyTableMap.IsOmikaTable("TableInexistante"));
}
[Fact]
public void IsLegacyOnlyTable_IdentifiesDroppedTable()
{
// Intervention_Collaborateur existait dans la version Isis_BO mais pas dans OMIKA.
Assert.True(LegacyTableMap.IsLegacyOnlyTable("Intervention_Collaborateur"));
}
[Fact]
public void IsLegacyOnlyTable_OmikaTable_ReturnsFalse()
{
Assert.False(LegacyTableMap.IsLegacyOnlyTable("Client"));
}
}
@@ -0,0 +1,306 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Batik → canonique des collaborateurs (table Collaborateur).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class BatikCollaborateurMapperTests
{
// ---- 1. Collaborateur Batik minimal -------------------------------------------
[Fact]
public void CollaborateurBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.CollaborateurRow { COPKID = 1 };
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Civilite);
Assert.Null(result.Value.Statut);
Assert.Null(result.Value.Coordonnees);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Collaborateur Batik complet -------------------------------------------
[Fact]
public void CollaborateurBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 42,
COIDCivilite = 2,
CONom = "Dupont",
COPrenom = "Jean",
COTrigramme = "JD",
COTelFixe = "0472000000",
COTelMobile = "0600000000",
COEmail = "jean.dupont@ville-lyon.fr",
COIDStatut = 3,
COActif = true,
COIDImport = "IMP-B-42"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "C", PAValeur = "1", PALibelle = "M." },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "Actif" }
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row, referenceRows);
Collaborateur collaborateur = result.Value;
Assert.Equal("M.", collaborateur.Civilite!.Label);
Assert.Equal(2, collaborateur.Civilite!.LegacyId);
Assert.Equal("Actif", collaborateur.Statut!.Label);
Assert.Equal(3, collaborateur.Statut!.LegacyId);
Assert.Equal("Dupont", collaborateur.Nom);
Assert.Equal("Jean", collaborateur.Prenom);
Assert.Equal("JD", collaborateur.Code);
Assert.Equal("jean.dupont@ville-lyon.fr", collaborateur.Coordonnees!.Email);
Assert.Equal("0472000000", collaborateur.Coordonnees!.Phone);
Assert.Equal("0600000000", collaborateur.Coordonnees!.Mobile);
Assert.True(collaborateur.Actif);
Assert.Equal(42, collaborateur.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, collaborateur.SourceReference!.Source);
}
// ---- 3. Coordonnées nulles ----------------------------------------------------
[Fact]
public void CollaborateurBatik_EmptyCoordinates_ProducesNullCoordonnees()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 5,
CONom = "Martin",
COEmail = " ",
COTelFixe = "",
COTelMobile = null
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.Prenom);
Assert.Equal("Martin", result.Value.Nom);
}
[Fact]
public void CollaborateurBatik_PartialCoordinates_KeepsOnlyProvidedValues()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 6,
COEmail = "m@exemple.fr",
COTelFixe = null,
COTelMobile = "0600000000"
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.Coordonnees);
Assert.Equal("m@exemple.fr", result.Value.Coordonnees!.Email);
Assert.Null(result.Value.Coordonnees!.Phone);
Assert.Equal("0600000000", result.Value.Coordonnees!.Mobile);
}
// ---- 4. Conservation de l'identifiant source ----------------------------------
[Fact]
public void CollaborateurBatik_SourceIdentifier_IsPreservedWithoutFabricatedGuid()
{
var row = new BatikModels.CollaborateurRow { COPKID = 1234, COIDImport = "IMP-B" };
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
Assert.Equal("1234", result.Value.SourceReference!.PublicIdentifier);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
}
// ---- 5. Aucune exception sur les chaînes vides ----------------------------------
[Fact]
public void CollaborateurBatik_EmptyOrNullStrings_NeverThrow()
{
var row = new BatikModels.CollaborateurRow
{
CONom = "",
COPrenom = " ",
COEmail = "",
COTelFixe = null,
COTelMobile = " ",
COTrigramme = "",
COIDImport = ""
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Coordonnees);
Assert.Empty(result.Warnings);
}
[Fact]
public void CollaborateurBatik_DefaultRow_NeverThrows()
{
var row = new BatikModels.CollaborateurRow();
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
}
// ---- 6. Absence de noms physiques HFSQL dans le domaine canonique --------------
[Fact]
public void CollaborateurBatik_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 40,
COIDCivilite = 2,
CONom = "Dupont",
COPrenom = "Jean",
COTelFixe = "01",
COTelMobile = "06",
COEmail = "j@exemple.fr",
COActif = true,
COIDImport = "IMP-40"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "C", PAValeur = "1", PALibelle = "M." }
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row, referenceRows);
Collaborateur collaborateur = result.Value;
string[] forbidden =
[
"CO_", "CL_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(collaborateur, forbidden);
if (collaborateur.Coordonnees is not null)
{
AssertHasNoPhysicalNames(collaborateur.Coordonnees, forbidden);
}
if (collaborateur.SourceReference is not null)
{
AssertHasNoPhysicalNames(collaborateur.SourceReference, forbidden);
}
}
// ---- Référentiels non résolus --------------------------------------------------
[Fact]
public void CollaborateurBatik_UnresolvedCiviliteAndStatut_KeepsLegacyIdAndWarns()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 7,
COIDCivilite = 77,
COIDStatut = 88
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal(77, result.Value.Civilite!.LegacyId);
Assert.Equal(string.Empty, result.Value.Civilite!.Label);
Assert.NotNull(result.Value.Statut);
Assert.Equal(88, result.Value.Statut!.LegacyId);
Assert.Contains(result.Warnings, w => w.Contains("CO_IDCivilite", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
Assert.Contains(result.Warnings, w => w.Contains("CO_IDStatut", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void CollaborateurBatik_ZeroCiviliteAndStatut_ProducesNoReference()
{
var row = new BatikModels.CollaborateurRow { COPKID = 8, COIDCivilite = 0, COIDStatut = 0 };
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.Null(result.Value.Civilite);
Assert.Null(result.Value.Statut);
Assert.Empty(result.Warnings);
}
// ---- Rubriques sans équivalent --------------------------------------------------
[Fact]
public void CollaborateurBatik_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new BatikModels.CollaborateurRow
{
COPKID = 9,
COFonction = "Développeur",
COPonderation = 1.5f,
COIDImport = "IMP-9",
COAuPlanning = true,
COAccesBO = true,
COAccesMobile = true,
CreationDate = new DateTime(2024, 1, 1),
ModifDate = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Collaborateur> result = BatikMappers.BatikCollaborateurMapper.Map(row);
Assert.Equal("Développeur", result.Value.Fonction);
Assert.Contains(result.IgnoredFields, f => f == "CO_Ponderation");
Assert.Contains(result.IgnoredFields, f => f == "CO_IDImport");
Assert.Contains(result.IgnoredFields, f => f == "CO_AuPlanning");
Assert.Contains(result.IgnoredFields, f => f == "CO_AccesBO");
Assert.Contains(result.IgnoredFields, f => f == "CO_AccesMobile");
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "ModifDate");
Assert.Null(result.Value.SocieteId);
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,332 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Batik → canonique des contacts (table Personne).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class BatikContactMapperTests
{
// ---- 1. Contact Batik minimal -------------------------------------------------
[Fact]
public void ContactBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.PersonneRow { PEPKID = 1 };
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Civilite);
Assert.Empty(result.Value.Roles);
Assert.Null(result.Value.Coordonnees);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Contact Batik complet -------------------------------------------------
[Fact]
public void ContactBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 42,
PEIDCivilite = 2,
PENom = "Dupont",
PEPrenom = "Jean",
PEIDRole = 3,
PETelFixe = "0472000000",
PETelMobile = "0600000000",
PEEmail = "jean.dupont@ville-lyon.fr",
PEFax = "0472000001",
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "Contact principal",
PEActif = true,
PEUsageInterne = false,
PEInfoSupp = "Disponible en journée",
Archive = true,
PEIDImport = "IMP-B-42"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "C", PAValeur = "1", PALibelle = "M." },
[3] = new() { PAPKID = 3, PACode = "R", PAValeur = "1", PALibelle = "Contact principal" }
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row, referenceRows);
Contact contact = result.Value;
Assert.Equal("M.", contact.Civilite!.Label);
Assert.Equal(2, contact.Civilite!.LegacyId);
Assert.Equal("Dupont", contact.Nom);
Assert.Equal("Jean", contact.Prenom);
Assert.Equal("Jean Dupont", contact.NomAffiche);
Assert.Single(contact.Roles);
Assert.Equal("Contact principal", contact.Roles[0].Label);
Assert.Equal(3, contact.Roles[0].LegacyId);
Assert.Equal("jean.dupont@ville-lyon.fr", contact.Coordonnees!.Email);
Assert.Equal("0472000000", contact.Coordonnees!.Phone);
Assert.Equal("0600000000", contact.Coordonnees!.Mobile);
Assert.Equal("0472000001", contact.Coordonnees!.Fax);
Assert.True(contact.Actif);
Assert.True(contact.EstArchive);
Assert.Equal("Disponible en journée", contact.InformationsSupplementaires);
Assert.Equal(42, contact.SourceReference!.NumericId);
Assert.Equal("IMP-B-42", contact.SourceReference!.ImportId);
Assert.Equal(DatabaseSchemaVersion.Batik, contact.SourceReference!.Source);
}
// ---- 3. Coordonnées nulles ----------------------------------------------------
[Fact]
public void ContactBatik_EmptyCoordinates_ProducesNullCoordonnees()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 5,
PENom = "Martin",
PEEmail = " ",
PETelFixe = "",
PETelMobile = null,
PEFax = "\t"
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.Prenom);
Assert.Equal("Martin", result.Value.Nom);
}
[Fact]
public void ContactBatik_PartialCoordinates_KeepsOnlyProvidedValues()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 6,
PEEmail = "m@exemple.fr",
PETelFixe = null,
PETelMobile = "0600000000",
PEFax = ""
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.NotNull(result.Value.Coordonnees);
Assert.Equal("m@exemple.fr", result.Value.Coordonnees!.Email);
Assert.Null(result.Value.Coordonnees!.Phone);
Assert.Equal("0600000000", result.Value.Coordonnees!.Mobile);
Assert.Null(result.Value.Coordonnees!.Fax);
}
// ---- 4. Conservation de l'identifiant source ----------------------------------
[Fact]
public void ContactBatik_SourceIdentifier_IsPreservedWithoutFabricatedGuid()
{
var row = new BatikModels.PersonneRow { PEPKID = 1234, PEIDImport = "IMP-B" };
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
Assert.Equal("IMP-B", result.Value.SourceReference!.ImportId);
Assert.Equal("1234", result.Value.SourceReference!.PublicIdentifier);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
}
// ---- 5. Aucune exception sur les chaînes vides ----------------------------------
[Fact]
public void ContactBatik_EmptyOrNullStrings_NeverThrow()
{
var row = new BatikModels.PersonneRow
{
PENom = "",
PEPrenom = " ",
PEEmail = "",
PETelFixe = null,
PETelMobile = " ",
PEFax = "",
PENote = "",
PEInfoSupp = " ",
PEIDImport = ""
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.InformationsSupplementaires);
Assert.Empty(result.Warnings);
}
[Fact]
public void ContactBatik_DefaultRow_NeverThrows()
{
var row = new BatikModels.PersonneRow();
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
}
// ---- 6. Absence de noms physiques HFSQL dans le domaine canonique --------------
[Fact]
public void ContactBatik_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 40,
PEIDCivilite = 2,
PENom = "Dupont",
PEPrenom = "Jean",
PEIDRole = 3,
PEEmail = "j@exemple.fr",
PETelFixe = "01",
PETelMobile = "06",
PEFax = "02",
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "note",
PEActif = true,
PEUsageInterne = true,
PEInfoSupp = "info",
Archive = false,
PEIDImport = "IMP-40"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "C", PAValeur = "1", PALibelle = "M." },
[3] = new() { PAPKID = 3, PACode = "R", PAValeur = "1", PALibelle = "Rôle" }
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row, referenceRows);
Contact contact = result.Value;
string[] forbidden =
[
"PE_", "CL_", "AD_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(contact, forbidden);
if (contact.Coordonnees is not null)
{
AssertHasNoPhysicalNames(contact.Coordonnees, forbidden);
}
if (contact.SourceReference is not null)
{
AssertHasNoPhysicalNames(contact.SourceReference, forbidden);
}
}
// ---- Référentiels non résolus --------------------------------------------------
[Fact]
public void ContactBatik_UnresolvedCiviliteAndRole_KeepsLegacyIdAndWarns()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 7,
PEIDCivilite = 77,
PEIDRole = 88
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal(77, result.Value.Civilite!.LegacyId);
Assert.Equal(string.Empty, result.Value.Civilite!.Label);
Assert.Single(result.Value.Roles);
Assert.Equal(88, result.Value.Roles[0].LegacyId);
Assert.Contains(result.Warnings, w => w.Contains("PE_IDCivilite", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
Assert.Contains(result.Warnings, w => w.Contains("PE_IDRole", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void ContactBatik_ZeroCiviliteAndRole_ProducesNoReference()
{
var row = new BatikModels.PersonneRow { PEPKID = 8, PEIDCivilite = 0, PEIDRole = 0 };
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.Null(result.Value.Civilite);
Assert.Empty(result.Value.Roles);
Assert.Empty(result.Warnings);
}
// ---- Rubriques sans équivalent --------------------------------------------------
[Fact]
public void ContactBatik_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new BatikModels.PersonneRow
{
PEPKID = 9,
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "note",
PEUsageInterne = true,
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1),
DateArchivage = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Contact> result = BatikMappers.BatikContactMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "PE_IDClient");
Assert.Contains(result.IgnoredFields, f => f == "PE_IDAdressePrincipale");
Assert.Contains(result.IgnoredFields, f => f == "PE_Note");
Assert.Contains(result.IgnoredFields, f => f == "PE_UsageInterne");
Assert.Contains(result.IgnoredFields, f => f == "ModifDate");
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
Assert.Null(result.Value.ClientId);
Assert.Empty(result.Value.SiteIds);
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,181 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Batik → canonique des dossiers (table Dossier).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class BatikDossierMapperTests
{
// ---- 1. Dossier Batik minimal ------------------------------------------------
[Fact]
public void DossierBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.DossierRow { DOPKID = 1 };
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.ReferenceMetier);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Statut);
Assert.False(result.Value.EstArchive);
Assert.Empty(result.Warnings);
}
// ---- 2. Conservation de l'identifiant source (cause du count=0 snapshot) -----
[Fact]
public void DossierBatik_SourceReference_IsPreservedWithNumericId()
{
// Cause réelle du micro-lot 19 : le mapper ne remplissait pas SourceReference,
// donc LoadDossiersAsync skippait tous les dossiers (NumericId null) → count=0.
var row = new BatikModels.DossierRow { DOPKID = 1234 };
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
Assert.Equal("1234", result.Value.SourceReference!.PublicIdentifier);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
}
// ---- 3. Dossier Batik complet ------------------------------------------------
[Fact]
public void DossierBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.DossierRow
{
DOPKID = 42,
DOIDTypeDossier = 2,
DOIDStatut = 3,
DOTitre = "Dossier chantier",
DONumDossier = "DOS-2024-001",
DOIDClient = 7,
Archive = false,
DODateDebut = new DateOnly(2024, 1, 15)
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Chantier" },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "Ouvert" }
};
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row, referenceRows);
Dossier dossier = result.Value;
Assert.Equal("DOS-2024-001", dossier.ReferenceMetier);
Assert.Equal("Dossier chantier", dossier.Titre);
Assert.Equal("Chantier", dossier.Type!.Label);
Assert.Equal(2, dossier.Type!.LegacyId);
Assert.Equal("Ouvert", dossier.Statut!.Label);
Assert.Equal(3, dossier.Statut!.LegacyId);
Assert.Equal(new DateTime(2024, 1, 15), dossier.DateOuverture);
Assert.False(dossier.EstArchive);
Assert.Equal(42, dossier.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, dossier.SourceReference!.Source);
// La référence physique du client lié est conservée en interne.
Assert.NotNull(dossier.ClientSourceReference);
Assert.Equal(7, dossier.ClientSourceReference!.NumericId);
}
// ---- 4. Aucune exception sur les chaînes vides --------------------------------
[Fact]
public void DossierBatik_EmptyOrNullStrings_NeverThrow()
{
var row = new BatikModels.DossierRow
{
DOPKID = 5,
DOTitre = "",
DONumDossier = " "
};
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.ReferenceMetier);
Assert.Empty(result.Warnings);
}
[Fact]
public void DossierBatik_DefaultRow_NeverThrows()
{
var row = new BatikModels.DossierRow();
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row);
Assert.NotNull(result.Value);
// SourceReference est toujours rempli (porte DO_PKID) ; avec une ligne par
// défaut DO_PKID = 0, NumericId vaut 0 (jamais null en production car la
// clé primaire est auto-générée et strictement positive).
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(0, result.Value.SourceReference!.NumericId);
Assert.False(result.Value.EstArchive);
}
// ---- 5. Absence de noms physiques HFSQL dans le domaine canonique --------------
[Fact]
public void DossierBatik_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new BatikModels.DossierRow
{
DOPKID = 40,
DOIDTypeDossier = 2,
DOIDStatut = 3,
DOTitre = "Dossier",
DONumDossier = "DOS-40",
DOIDClient = 7,
Archive = false
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Chantier" },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "Ouvert" }
};
CanonicalMappingResult<Dossier> result = BatikMappers.BatikDossierMapper.Map(row, referenceRows);
Dossier dossier = result.Value;
string[] forbidden =
[
"DO_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(dossier, forbidden);
if (dossier.SourceReference is not null)
{
AssertHasNoPhysicalNames(dossier.SourceReference, forbidden);
}
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,281 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Batik → canonique des interventions.
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class BatikInterventionMapperTests
{
// ---- 1. Intervention Batik minimale -------------------------------------------
[Fact]
public void InterventionBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.InterventionRow { INPKID = 1 };
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.Reference);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Etat);
Assert.False(result.Value.EstArchive);
Assert.Empty(result.Warnings);
}
// ---- 2. Intervention Batik complète -------------------------------------------
[Fact]
public void InterventionBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.InterventionRow
{
INPKID = 42,
INTitre = "Réparation chaudière",
INRefInterne = "INT-042",
INDescription = "Remplacement de la chaudière",
INIDTypeIntervention = 2,
INIDEtatIntervention = 3,
INDateIntervention = new DateOnly(2026, 3, 15),
Archive = true,
INIDImport = "IMP-B-42"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Maintenance" },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "En cours" }
};
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row, referenceRows);
Intervention intervention = result.Value;
Assert.Equal("Réparation chaudière", intervention.Titre);
Assert.Equal("INT-042", intervention.Reference);
Assert.Equal("Remplacement de la chaudière", intervention.Description);
Assert.Equal("Maintenance", intervention.Type!.Label);
Assert.Equal(2, intervention.Type!.LegacyId);
Assert.Equal("En cours", intervention.Etat!.Label);
Assert.Equal(3, intervention.Etat!.LegacyId);
Assert.Equal(new DateTime(2026, 3, 15), intervention.DatePrevueDebut);
Assert.True(intervention.EstArchive);
}
// ---- 4. Type et état ----------------------------------------------------------
[Fact]
public void InterventionBatik_Type_IsResolvedFromLegacyId()
{
var row = new BatikModels.InterventionRow { INPKID = 5, INIDTypeIntervention = 7 };
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[7] = new() { PAPKID = 7, PACode = "T", PAValeur = "2", PALibelle = "Urgence" }
};
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row, referenceRows);
Assert.NotNull(result.Value.Type);
Assert.Equal("Urgence", result.Value.Type!.Label);
Assert.Equal(7, result.Value.Type!.LegacyId);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionBatik_UnresolvedType_KeepsLegacyIdAndWarns()
{
var row = new BatikModels.InterventionRow { INPKID = 6, INIDTypeIntervention = 77 };
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.NotNull(result.Value.Type);
Assert.Equal(77, result.Value.Type!.LegacyId);
Assert.Equal(string.Empty, result.Value.Type!.Label);
Assert.Contains(result.Warnings, w => w.Contains("IN_IDTypeIntervention", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void InterventionBatik_ZeroType_ProducesNoReference()
{
var row = new BatikModels.InterventionRow { INPKID = 8, INIDTypeIntervention = 0 };
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.Null(result.Value.Type);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionBatik_UnresolvedEtat_KeepsLegacyIdAndWarns()
{
var row = new BatikModels.InterventionRow { INPKID = 9, INIDEtatIntervention = 88 };
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.NotNull(result.Value.Etat);
Assert.Equal(88, result.Value.Etat!.LegacyId);
Assert.Contains(result.Warnings, w => w.Contains("IN_IDEtatIntervention", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
// ---- 5. Champs ignorés --------------------------------------------------------
[Fact]
public void InterventionBatik_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new BatikModels.InterventionRow
{
INPKID = 15,
INIDClient = 1,
INIDLieu = 2,
INIDDossier = 3,
INIDSuiviPar = 4,
INIDDemandeur = 5,
INIDSociete = 6,
INAttestationTVAPresente = true,
INNoteInterne = "note",
INRefExterne = "EXT-1",
INDureePrevue = 2.5f,
INNumDevis = "DEV-1",
INDateDevis = new DateOnly(2026, 1, 1),
INNumFacture = "FAC-1",
INDateFacture = new DateOnly(2026, 2, 1),
INNumIntervention = "N-1",
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1),
DateArchivage = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "IN_IDClient");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDLieu");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDDossier");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDSuiviPar");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDDemandeur");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDSociete");
Assert.Contains(result.IgnoredFields, f => f == "IN_AttestationTVAPresente");
Assert.Contains(result.IgnoredFields, f => f == "IN_NoteInterne");
Assert.Contains(result.IgnoredFields, f => f == "IN_DureePrevue");
Assert.Contains(result.IgnoredFields, f => f == "IN_DateDevis");
Assert.Contains(result.IgnoredFields, f => f == "IN_DateFacture");
Assert.Equal(new DateTime(2024, 1, 1), result.Value.DateModification);
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
// Champs désormais dotés d'un équivalent canonique : ils ne sont plus
// ignorés, ils sont mappés dans l'entité Intervention.
Assert.Equal("EXT-1", result.Value.ReferenceExterne);
Assert.Equal("DEV-1", result.Value.NumeroDevis);
Assert.Equal("N-1", result.Value.NumeroIntervention);
Assert.Equal("FAC-1", result.Value.NumeroFacture);
}
// ---- 6. Aucune exception sur les chaînes vides --------------------------------
[Fact]
public void InterventionBatik_EmptyOrNullStrings_NeverThrow()
{
var row = new BatikModels.InterventionRow
{
INTitre = "",
INRefInterne = " ",
INDescription = "",
INNoteInterne = " ",
INRefExterne = "",
INIDImport = " "
};
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.Reference);
Assert.Null(result.Value.Description);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionBatik_DefaultRow_NeverThrows()
{
var row = new BatikModels.InterventionRow();
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row);
Assert.NotNull(result.Value);
Assert.False(result.Value.EstArchive);
}
// ---- 7. Absence de noms physiques HFSQL dans le domaine canonique -------------
[Fact]
public void InterventionBatik_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new BatikModels.InterventionRow
{
INPKID = 40,
INTitre = "Titre",
INRefInterne = "INT-040",
INDescription = "desc",
INIDTypeIntervention = 2,
INIDEtatIntervention = 3,
INDateIntervention = new DateOnly(2026, 1, 1),
Archive = false,
INIDImport = "IMP-40",
INIDClient = 1,
INIDLieu = 2,
INIDDossier = 3,
INIDSuiviPar = 4,
INIDDemandeur = 5,
INIDSociete = 6,
INAttestationTVAPresente = true,
INNoteInterne = "note",
INRefExterne = "EXT",
INDureePrevue = 1f,
INNumDevis = "DEV",
INNumFacture = "FAC",
INNumIntervention = "N"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Maintenance" },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "En cours" }
};
CanonicalMappingResult<Intervention> result = BatikMappers.BatikInterventionMapper.Map(row, referenceRows);
Intervention intervention = result.Value;
string[] forbidden =
[
"IN_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(intervention, forbidden);
if (intervention.SourceReference is not null)
{
AssertHasNoPhysicalNames(intervention.SourceReference, forbidden);
}
}
// ---- Helper -------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,89 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Planning;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapping Batik des valeurs de référentiel d'annulation d'un
/// rendez-vous. Aucune base réelle : uniquement des objets en mémoire.
///
/// En Batik, l'origine et le motif d'annulation sont stockés sous forme
/// d'identifiants numériques (<c>PL_IDOrigineAnnul</c> / <c>PL_IDMotifAnnul</c> →
/// <c>PA_PKID</c>). Ils doivent être résolus dans le référentiel ParametreMetier,
/// sans quoi la fiche du rendez-vous n'affiche aucun libellé.
/// </summary>
public class BatikRendezVousMapperTests
{
[Fact]
public void Map_AnnulationAvecReferentiel_LibellesOrigineEtMotifResolusParPkId()
{
var row = new BatikModels.PlanningRDVRow
{
PLPKID = 42,
PLDateDebut = new DateTime(2026, 8, 19, 9, 0, 0),
PLDateFin = new DateTime(2026, 8, 19, 10, 0, 0),
PLIDOrigineAnnul = 2000UL,
PLIDMotifAnnul = 3000UL
};
var references = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2000L] = new BatikModels.ParametreMetierRow
{
PAPKID = 2000L,
PACode = "34",
PALibelle = "Externe : Client"
},
[3000L] = new BatikModels.ParametreMetierRow
{
PAPKID = 3000L,
PACode = "35",
PALibelle = "Absent"
}
};
CanonicalMappingResult<RendezVous> mapped = BatikMappers.BatikRendezVousMapper.Map(row, references);
Assert.NotNull(mapped.Value.Annulation);
Assert.Equal(2000L, mapped.Value.Annulation!.Origine!.LegacyId);
Assert.Equal("Externe : Client", mapped.Value.Annulation.Origine!.Label);
Assert.Equal(3000L, mapped.Value.Annulation.Motif!.LegacyId);
Assert.Equal("Absent", mapped.Value.Annulation.Motif!.Label);
}
[Fact]
public void Map_AnnulationSansReferentiel_ConserveLIdentifiantSansInventerDeLibelle()
{
var row = new BatikModels.PlanningRDVRow
{
PLPKID = 43,
PLDateDebut = new DateTime(2026, 8, 19, 9, 0, 0),
PLDateFin = new DateTime(2026, 8, 19, 10, 0, 0),
PLIDMotifAnnul = 3000UL
};
CanonicalMappingResult<RendezVous> mapped = BatikMappers.BatikRendezVousMapper.Map(row, null);
Assert.NotNull(mapped.Value.Annulation);
Assert.Equal(3000L, mapped.Value.Annulation!.Motif!.LegacyId);
Assert.True(string.IsNullOrEmpty(mapped.Value.Annulation.Motif!.Label));
Assert.Null(mapped.Value.Annulation.Origine);
}
[Fact]
public void Map_SansAnnulation_AucuneAnnulation()
{
var row = new BatikModels.PlanningRDVRow
{
PLPKID = 44,
PLDateDebut = new DateTime(2026, 8, 19, 9, 0, 0),
PLDateFin = new DateTime(2026, 8, 19, 10, 0, 0)
};
CanonicalMappingResult<RendezVous> mapped = BatikMappers.BatikRendezVousMapper.Map(row, null);
Assert.Null(mapped.Value.Annulation);
}
}
@@ -0,0 +1,355 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using BatikMappers = Omika.Data.Mapping.Batik;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Batik → canonique des sites (table Lieu).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class BatikSiteMapperTests
{
// ---- 1. Site Batik minimal -------------------------------------------------
[Fact]
public void SiteBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.LieuRow { LIPKID = 1 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Code);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Adresse);
Assert.Null(result.Value.InformationsComplementaires);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Site Batik complet -------------------------------------------------
[Fact]
public void SiteBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.LieuRow
{
LIPKID = 42,
LICodeLieu = "S-001",
LIIDType = 2,
LINote = "Site principal",
Archive = true,
LIIDImport = "IMP-B-42"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Immeuble" }
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row, referenceRows);
Site site = result.Value;
Assert.Equal("S-001", site.Code);
Assert.Equal("Immeuble", site.Type!.Label);
Assert.Equal(2, site.Type!.LegacyId);
Assert.Equal("T", site.Type!.Code);
Assert.Equal("Site principal", site.InformationsComplementaires);
Assert.True(site.EstArchive);
Assert.Equal(42, site.SourceReference!.NumericId);
Assert.Equal("IMP-B-42", site.SourceReference!.ImportId);
Assert.Equal(DatabaseSchemaVersion.Batik, site.SourceReference!.Source);
}
// ---- 3. Conservation de l'identifiant source ----------------------------------
[Fact]
public void SiteBatik_SourceIdentifier_IsPreservedWithoutFabricatedGuid()
{
var row = new BatikModels.LieuRow { LIPKID = 1234, LIIDImport = "IMP-B" };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
Assert.Equal("IMP-B", result.Value.SourceReference!.ImportId);
Assert.Equal("1234", result.Value.SourceReference!.PublicIdentifier);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
}
// ---- 4. Type de site ----------------------------------------------------------
[Fact]
public void SiteBatik_Type_IsResolvedFromLegacyId()
{
var row = new BatikModels.LieuRow { LIPKID = 5, LIIDType = 7 };
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[7] = new() { PAPKID = 7, PACode = "T", PAValeur = "2", PALibelle = "Maison Indiv." }
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row, referenceRows);
Assert.NotNull(result.Value.Type);
Assert.Equal("Maison Indiv.", result.Value.Type!.Label);
Assert.Equal(7, result.Value.Type!.LegacyId);
Assert.Empty(result.Warnings);
}
[Fact]
public void SiteBatik_UnresolvedType_KeepsLegacyIdAndWarns()
{
var row = new BatikModels.LieuRow { LIPKID = 6, LIIDType = 77 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.NotNull(result.Value.Type);
Assert.Equal(77, result.Value.Type!.LegacyId);
Assert.Equal(string.Empty, result.Value.Type!.Label);
Assert.Contains(result.Warnings, w => w.Contains("LI_IDType", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void SiteBatik_ZeroType_ProducesNoReference()
{
var row = new BatikModels.LieuRow { LIPKID = 8, LIIDType = 0 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Null(result.Value.Type);
Assert.Empty(result.Warnings);
}
// ---- 5. Informations d'accès --------------------------------------------
[Fact]
public void SiteBatik_AccessInformation_CodeAccesBadgeInterphone_Mapped()
{
var row = new BatikModels.LieuRow
{
LIPKID = 9,
LICodeAcces = "Code 1234",
LIBadge = true,
LIInterphone = true
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Equal("Code 1234", result.Value.CodeAcces);
Assert.True(result.Value.Badge);
Assert.True(result.Value.Interphone);
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_CodeAcces");
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_Badge");
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_Interphone");
}
// ---- 6. Blocage sans équivalent métier ----------------------------------------
[Fact]
public void SiteBatik_Blocage_IsReportedAsIgnored()
{
var row = new BatikModels.LieuRow
{
LIPKID = 10,
LIBlocage = true,
LIRaisonBlocage = "Travaux en cours"
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "LI_Blocage");
Assert.Contains(result.IgnoredFields, f => f == "LI_RaisonBlocage");
}
// ---- 7. Adresse déjà chargée en mémoire ---------------------------------------
[Fact]
public void SiteBatik_Adresse_IsMappedFromInMemoryDictionary()
{
var row = new BatikModels.LieuRow { LIPKID = 11, LIIDAdresse = 20 };
var adresses = new Dictionary<long, BatikModels.AdresseRow>
{
[20] = new() { ADPKID = 20, ADEtiquette = "Siège", ADLigne1 = "1 place de la Comédie", ADVille = "Lyon", ADCodePostal = "69001" }
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row, adresses: adresses);
Assert.NotNull(result.Value.Adresse);
Assert.Equal("1 place de la Comédie", result.Value.Adresse!.Line1);
Assert.Equal("69001", result.Value.Adresse!.PostalCode);
Assert.Equal("Lyon", result.Value.Adresse!.City);
}
[Fact]
public void SiteBatik_NoAdresse_WhenNothingProvided()
{
var row = new BatikModels.LieuRow { LIPKID = 12, LIIDAdresse = 0 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Null(result.Value.Adresse);
Assert.Empty(result.Warnings);
}
[Fact]
public void SiteBatik_UnresolvedAdresse_WarnsAndProducesNull()
{
var row = new BatikModels.LieuRow { LIPKID = 13, LIIDAdresse = 99 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Null(result.Value.Adresse);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
// ---- 8. Relations différées ----------------------------------------------------
[Fact]
public void SiteBatik_Relations_AreLeftForLaterAssembly()
{
var row = new BatikModels.LieuRow { LIPKID = 14, LIIDClientPrincipal = 30 };
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Null(result.Value.ClientId);
Assert.Empty(result.Value.ContactIds);
Assert.Contains(result.IgnoredFields, f => f == "LI_IDClientPrincipal");
}
// ---- 9. Aucune exception sur les chaînes vides ---------------------------------
[Fact]
public void SiteBatik_EmptyOrNullStrings_NeverThrow()
{
var row = new BatikModels.LieuRow
{
LICodeLieu = "",
LINote = " ",
LIIDImport = "",
LICodeAcces = " ",
LIRaisonBlocage = "",
LIRepertoireDocs = " "
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Code);
Assert.Null(result.Value.InformationsComplementaires);
Assert.Null(result.Value.SourceReference!.ImportId);
Assert.Empty(result.Warnings);
}
[Fact]
public void SiteBatik_DefaultRow_NeverThrows()
{
var row = new BatikModels.LieuRow();
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.EstArchive);
}
// ---- 10. Champs ignorés et warnings attendus -----------------------------------
[Fact]
public void SiteBatik_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new BatikModels.LieuRow
{
LIPKID = 15,
LIRepertoireDocs = "docs/site",
DateArchivage = new DateTime(2024, 6, 1),
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1)
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "LI_RepertoireDocs");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
Assert.Equal(new DateTime(2024, 1, 1), result.Value.DateModification);
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
}
// ---- 11. Absence de noms physiques HFSQL dans le domaine canonique -------------
[Fact]
public void SiteBatik_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new BatikModels.LieuRow
{
LIPKID = 40,
LICodeLieu = "S-040",
LIIDType = 2,
LINote = "note",
Archive = false,
LIIDImport = "IMP-40",
LIIDAdresse = 20,
LIIDClientPrincipal = 30,
LICodeAcces = "code",
LIBadge = true,
LIInterphone = true,
LIBlocage = true,
LIRaisonBlocage = "raison",
LIRepertoireDocs = "docs"
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Immeuble" }
};
var adresses = new Dictionary<long, BatikModels.AdresseRow>
{
[20] = new() { ADPKID = 20, ADLigne1 = "1 rue de la Paix", ADVille = "Lyon", ADCodePostal = "69001" }
};
CanonicalMappingResult<Site> result = BatikMappers.BatikSiteMapper.Map(row, referenceRows, adresses);
Site site = result.Value;
string[] forbidden =
[
"LI_", "AD_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(site, forbidden);
if (site.Adresse is not null)
{
AssertHasNoPhysicalNames(site.Adresse, forbidden);
}
if (site.SourceReference is not null)
{
AssertHasNoPhysicalNames(site.SourceReference, forbidden);
}
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,416 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Data.Mapping.Common;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires de l'assembleur d'agrégat <see cref="ClientAggregateAssembler"/>.
/// Aucune base réelle, aucun HFSQL : uniquement des objets canoniques en mémoire.
/// </summary>
public class ClientAggregateAssemblerTests
{
// ---- 1. Client seul avec collections vides -------------------------------------
[Fact]
public void Assemble_ClientAlone_KeepsCollectionsEmpty()
{
var client = new Client { Nom = "Ville de Lyon" };
Client result = ClientAggregateAssembler.Assemble(client);
Assert.Same(client, result);
Assert.Empty(result.Adresses);
Assert.Empty(result.Contacts);
Assert.Empty(result.Sites);
Assert.Null(result.AdressePrincipale);
Assert.Null(result.ContactPrincipal);
}
// ---- 2. Assemblage d'une adresse ------------------------------------------------
[Fact]
public void Assemble_SingleAdresse_IsAddedToCollection()
{
var client = new Client();
var adresse = new Adresse { Etiquette = "Mairie" };
ClientAggregateAssembler.Assemble(client, adresses: [adresse]);
Assert.Single(client.Adresses);
Assert.Same(adresse, client.Adresses[0]);
}
// ---- 3. Plusieurs adresses ------------------------------------------------------
[Fact]
public void Assemble_MultipleAdresses_AreAllAdded()
{
var client = new Client();
var a1 = new Adresse { Etiquette = "Mairie" };
var a2 = new Adresse { Etiquette = "Agence" };
ClientAggregateAssembler.Assemble(client, adresses: [a1, a2]);
Assert.Equal(2, client.Adresses.Count);
Assert.Contains(a1, client.Adresses);
Assert.Contains(a2, client.Adresses);
}
// ---- 4. Assemblage d'un contact -------------------------------------------------
[Fact]
public void Assemble_SingleContact_IsAddedToCollection()
{
var client = new Client();
var contact = new Contact { Nom = "Dupont" };
ClientAggregateAssembler.Assemble(client, contacts: [contact]);
Assert.Single(client.Contacts);
Assert.Same(contact, client.Contacts[0]);
}
// ---- 5. Plusieurs contacts ------------------------------------------------------
[Fact]
public void Assemble_MultipleContacts_AreAllAdded()
{
var client = new Client();
var c1 = new Contact { Nom = "Dupont" };
var c2 = new Contact { Nom = "Martin" };
ClientAggregateAssembler.Assemble(client, contacts: [c1, c2]);
Assert.Equal(2, client.Contacts.Count);
Assert.Contains(c1, client.Contacts);
Assert.Contains(c2, client.Contacts);
}
// ---- 6. Assemblage d'un site ----------------------------------------------------
[Fact]
public void Assemble_SingleSite_IsAddedToCollection()
{
var client = new Client();
var site = new Site { Nom = "Hôtel de ville" };
ClientAggregateAssembler.Assemble(client, sites: [site]);
Assert.Single(client.Sites);
Assert.Same(site, client.Sites[0]);
}
// ---- 7. Plusieurs sites ---------------------------------------------------------
[Fact]
public void Assemble_MultipleSites_AreAllAdded()
{
var client = new Client();
var s1 = new Site { Nom = "Hôtel de ville" };
var s2 = new Site { Nom = "Agence" };
ClientAggregateAssembler.Assemble(client, sites: [s1, s2]);
Assert.Equal(2, client.Sites.Count);
Assert.Contains(s1, client.Sites);
Assert.Contains(s2, client.Sites);
}
// ---- 8. Toutes les collections ensemble -----------------------------------------
[Fact]
public void Assemble_AllCollections_AreAssembled()
{
var client = new Client();
var adresse = new Adresse { Etiquette = "Mairie" };
var contact = new Contact { Nom = "Dupont" };
var site = new Site { Nom = "Hôtel de ville" };
ClientAggregateAssembler.Assemble(client, [adresse], [contact], [site]);
Assert.Single(client.Adresses);
Assert.Single(client.Contacts);
Assert.Single(client.Sites);
}
// ---- 9. Détection de l'adresse principale ---------------------------------------
[Fact]
public void Assemble_AdressePrincipale_AlreadyMapped_IsNotDuplicatedInCollection()
{
var principale = new Adresse
{
Etiquette = "Mairie",
EstPrincipale = true,
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
var client = new Client { AdressePrincipale = principale };
var secondaire = new Adresse { Etiquette = "Agence" };
// La collection contient l'adresse principale (même identité) et une secondaire.
ClientAggregateAssembler.Assemble(client, adresses: [principale, secondaire]);
Assert.Same(principale, client.AdressePrincipale);
Assert.Single(client.Adresses);
Assert.Same(secondaire, client.Adresses[0]);
}
[Fact]
public void Assemble_AdressePrincipale_NotMapped_IsDetectedFromEstPrincipale()
{
var client = new Client();
var principale = new Adresse { Etiquette = "Mairie", EstPrincipale = true };
var secondaire = new Adresse { Etiquette = "Agence", EstPrincipale = false };
ClientAggregateAssembler.Assemble(client, adresses: [secondaire, principale]);
Assert.Same(principale, client.AdressePrincipale);
Assert.Equal(2, client.Adresses.Count);
}
[Fact]
public void Assemble_NoAdressePrincipale_WhenNoneIsMarked()
{
var client = new Client();
var adresse = new Adresse { Etiquette = "Agence", EstPrincipale = false };
ClientAggregateAssembler.Assemble(client, adresses: [adresse]);
Assert.Null(client.AdressePrincipale);
Assert.Single(client.Adresses);
}
// ---- 10. Contact principal ------------------------------------------------------
[Fact]
public void Assemble_ContactPrincipal_IsNotInvented()
{
var client = new Client();
var contact = new Contact { Nom = "Dupont" };
ClientAggregateAssembler.Assemble(client, contacts: [contact]);
Assert.Single(client.Contacts);
Assert.Null(client.ContactPrincipal);
}
// ---- 11. Site principal ---------------------------------------------------------
[Fact]
public void Assemble_SitePrincipal_IsNotInvented()
{
var client = new Client();
var site = new Site { Nom = "Hôtel de ville" };
ClientAggregateAssembler.Assemble(client, sites: [site]);
Assert.Single(client.Sites);
// Aucune propriété SitePrincipal n'existe dans Client : la notion reste non assemblée.
Assert.DoesNotContain(typeof(Client).GetProperties(), p => p.Name == "SitePrincipal");
}
// ---- 12. Doublon exact d'adresse éliminé ----------------------------------------
[Fact]
public void Assemble_DuplicateAdresse_IsEliminated()
{
var client = new Client();
var adresse = new Adresse
{
Etiquette = "Mairie",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
ClientAggregateAssembler.Assemble(client, adresses: [adresse, adresse]);
Assert.Single(client.Adresses);
}
// ---- 13. Doublon exact de contact éliminé ---------------------------------------
[Fact]
public void Assemble_DuplicateContact_IsEliminated()
{
var client = new Client();
var contact = new Contact
{
Nom = "Dupont",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
ClientAggregateAssembler.Assemble(client, contacts: [contact, contact]);
Assert.Single(client.Contacts);
}
// ---- 14. Doublon exact de site éliminé ------------------------------------------
[Fact]
public void Assemble_DuplicateSite_IsEliminated()
{
var client = new Client();
var site = new Site
{
Nom = "Hôtel de ville",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
ClientAggregateAssembler.Assemble(client, sites: [site, site]);
Assert.Single(client.Sites);
}
// ---- 15. Deux homonymes restent distincts ---------------------------------------
[Fact]
public void Assemble_Homonyms_RemainDistinct()
{
var client = new Client();
var c1 = new Contact
{
Nom = "Dupont",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 1)
};
var c2 = new Contact
{
Nom = "Dupont",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 2)
};
ClientAggregateAssembler.Assemble(client, contacts: [c1, c2]);
Assert.Equal(2, client.Contacts.Count);
}
// ---- 16. Même NumericId mais sources différentes restent distinctes -------------
[Fact]
public void Assemble_SameNumericId_DifferentSources_RemainDistinct()
{
var client = new Client();
var batik = new Adresse
{
Etiquette = "Mairie",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Batik, NumericId: 10)
};
var omika = new Adresse
{
Etiquette = "Mairie",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
ClientAggregateAssembler.Assemble(client, adresses: [batik, omika]);
Assert.Equal(2, client.Adresses.Count);
}
// ---- 17. GUID identique reconnu comme même entité -------------------------------
[Fact]
public void Assemble_SameCanonicalGuid_IsRecognizedAsSameEntity()
{
var client = new Client();
var guid = Guid.NewGuid();
var a1 = new Adresse { Id = guid, Etiquette = "Mairie" };
var a2 = new Adresse { Id = guid, Etiquette = "Mairie" };
ClientAggregateAssembler.Assemble(client, adresses: [a1, a2]);
Assert.Single(client.Adresses);
}
// ---- 18. Collections nulles ou vides sans exception -----------------------------
[Fact]
public void Assemble_NullCollections_DoNotThrow()
{
var client = new Client { Nom = "Ville" };
Client result = ClientAggregateAssembler.Assemble(client, null, null, null);
Assert.Same(client, result);
Assert.Empty(result.Adresses);
Assert.Empty(result.Contacts);
Assert.Empty(result.Sites);
}
[Fact]
public void Assemble_EmptyCollections_DoNotThrow()
{
var client = new Client { Nom = "Ville" };
ClientAggregateAssembler.Assemble(client, [], [], []);
Assert.Empty(client.Adresses);
Assert.Empty(client.Contacts);
Assert.Empty(client.Sites);
}
[Fact]
public void Assemble_NullEntries_AreIgnored()
{
var client = new Client();
Adresse? adresse = null;
Contact? contact = null;
Site? site = null;
// Les éléments null sont ignorés par l'assembleur.
ClientAggregateAssembler.Assemble(client, new[] { adresse! }, new[] { contact! }, new[] { site! });
Assert.Empty(client.Adresses);
Assert.Empty(client.Contacts);
Assert.Empty(client.Sites);
}
// ---- 19. Assemblage déterministe ------------------------------------------------
[Fact]
public void Assemble_IsDeterministic_AndIdempotent()
{
var client = new Client();
var adresse = new Adresse
{
Etiquette = "Mairie",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 10)
};
var contact = new Contact
{
Nom = "Dupont",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 20)
};
var site = new Site
{
Nom = "Hôtel de ville",
SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 30)
};
// Premier assemblage.
ClientAggregateAssembler.Assemble(client, [adresse], [contact], [site]);
Assert.Single(client.Adresses);
Assert.Single(client.Contacts);
Assert.Single(client.Sites);
// Second assemblage avec les mêmes objets : aucun doublon créé.
ClientAggregateAssembler.Assemble(client, [adresse], [contact], [site]);
Assert.Single(client.Adresses);
Assert.Single(client.Contacts);
Assert.Single(client.Sites);
}
// ---- 20. Aucun type physique HFSQL dans la signature publique -------------------
[Fact]
public void Assemble_PublicSignature_DoesNotExposePhysicalTypes()
{
var method = typeof(ClientAggregateAssembler).GetMethod(nameof(ClientAggregateAssembler.Assemble))!;
foreach (var parameter in method.GetParameters())
{
Assert.DoesNotContain("HfSql", parameter.ParameterType.FullName, StringComparison.Ordinal);
Assert.DoesNotContain("Row", parameter.ParameterType.Name, StringComparison.Ordinal);
}
Assert.Equal(typeof(Client), method.ReturnType);
}
}
@@ -0,0 +1,51 @@
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Documents;
using Omika.Core.Domain.Entities.Documents;
using Omika.Core.Mapping;
using Xunit;
namespace Omika.Tests.Mapping;
public class ModeleDocumentContractMapperTests
{
[Fact]
public void ModeleMailContractMapper_UsesSourceReferenceAsPublicId()
{
var modele = new ModeleMail
{
Id = Guid.Empty,
Code = "REL1",
Nom = "Relance",
Sujet = "Bonjour [[NomClient]]",
Corps = "Corps",
EnvoiMobile = true
};
modele.SourceReference = LegacyEntityReference.FromLegacyId(42);
ModeleMailDto dto = ModeleMailContractMapper.ToDto(modele);
Assert.Equal("42", dto.Id);
Assert.Equal("REL1", dto.Code);
Assert.Equal("Bonjour [[NomClient]]", dto.Sujet);
Assert.True(dto.EnvoiMobile);
}
[Fact]
public void ModeleRapportContractMapper_UsesSourceReferenceAsPublicId()
{
var modele = new ModeleRapport
{
Id = Guid.Empty,
Nom = "Compte rendu",
Modele = "Contenu [[Site]]",
EnvoiMobile = true
};
modele.SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 7);
ModeleRapportDto dto = ModeleRapportContractMapper.ToDto(modele);
Assert.Equal("7", dto.Id);
Assert.Equal("Compte rendu", dto.Nom);
Assert.True(dto.EnvoiMobile);
}
}
@@ -0,0 +1,324 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Omika → canonique des collaborateurs (table Collaborateur).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class OmikaCollaborateurMapperTests
{
[Fact]
public void CollaborateurOmika_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new OmikaModels.CollaborateurRow { COPKID = 1 };
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Civilite);
Assert.Null(result.Value.Statut);
Assert.Null(result.Value.Coordonnees);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value.SourceReference!.Source);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
[Fact]
public void CollaborateurOmika_Full_MapsAllCanonicalProperties()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 42,
COCleCivilite = "M",
CONom = "Dupont",
COPrenom = "Jean",
COTrigramme = "JD",
COTelFixe = "0472000000",
COTelMobile = "0600000000",
COEmail = "jean.dupont@ville-lyon.fr",
COCleStatut = "ACTIF",
COActif = true,
GUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["M"] = new() { PACLE = "M", PALibelle = "M.", PAPKID = 2 },
["ACTIF"] = new() { PACLE = "ACTIF", PALibelle = "Actif", PAPKID = 3 }
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row, referenceRows);
Collaborateur collaborateur = result.Value;
Assert.Equal("M.", collaborateur.Civilite!.Label);
Assert.Equal("M", collaborateur.Civilite!.Key);
Assert.Equal("Actif", collaborateur.Statut!.Label);
Assert.Equal("ACTIF", collaborateur.Statut!.Key);
Assert.Equal("Dupont", collaborateur.Nom);
Assert.Equal("Jean", collaborateur.Prenom);
Assert.Equal("JD", collaborateur.Code);
Assert.Equal("jean.dupont@ville-lyon.fr", collaborateur.Coordonnees!.Email);
Assert.Equal("0472000000", collaborateur.Coordonnees!.Phone);
Assert.Equal("0600000000", collaborateur.Coordonnees!.Mobile);
Assert.True(collaborateur.Actif);
Assert.Equal(42, collaborateur.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Omika, collaborateur.SourceReference!.Source);
Assert.Equal(Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), collaborateur.SourceReference!.EntityGuid);
}
[Fact]
public void CollaborateurOmika_EmptyCoordinates_ProducesNullCoordonnees()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 5,
CONom = "Martin",
COEmail = " ",
COTelFixe = "",
COTelMobile = null
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.Prenom);
Assert.Equal("Martin", result.Value.Nom);
}
[Fact]
public void CollaborateurOmika_PartialCoordinates_KeepsOnlyProvidedValues()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 6,
COEmail = "m@exemple.fr",
COTelFixe = null,
COTelMobile = "0600000000"
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.Coordonnees);
Assert.Equal("m@exemple.fr", result.Value.Coordonnees!.Email);
Assert.Null(result.Value.Coordonnees!.Phone);
Assert.Equal("0600000000", result.Value.Coordonnees!.Mobile);
}
[Fact]
public void CollaborateurOmika_InvalidGuid_ProducesNullEntityGuidAndWarns()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 7,
GUID = "not-a-guid"
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Equal("not-a-guid", result.Value.SourceReference!.PhysicalGuid);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
}
[Fact]
public void CollaborateurOmika_NoGuid_ProducesNullEntityGuid()
{
var row = new OmikaModels.CollaborateurRow { COPKID = 8 };
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
Assert.Empty(result.Warnings);
}
[Fact]
public void CollaborateurOmika_EmptyOrNullStrings_NeverThrow()
{
var row = new OmikaModels.CollaborateurRow
{
CONom = "",
COPrenom = " ",
COEmail = "",
COTelFixe = null,
COTelMobile = " ",
COTrigramme = "",
COCleCivilite = "",
COCleStatut = null
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.Civilite);
Assert.Null(result.Value.Statut);
}
[Fact]
public void CollaborateurOmika_DefaultRow_NeverThrows()
{
var row = new OmikaModels.CollaborateurRow();
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
}
[Fact]
public void CollaborateurOmika_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 40,
COCleCivilite = "M",
CONom = "Dupont",
COPrenom = "Jean",
COTelFixe = "01",
COTelMobile = "06",
COEmail = "j@exemple.fr",
COActif = true,
GUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["M"] = new() { PACLE = "M", PALibelle = "M.", PAPKID = 2 }
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row, referenceRows);
Collaborateur collaborateur = result.Value;
string[] forbidden =
[
"CO_", "CL_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(collaborateur, forbidden);
if (collaborateur.Coordonnees is not null)
{
AssertHasNoPhysicalNames(collaborateur.Coordonnees, forbidden);
}
if (collaborateur.SourceReference is not null)
{
AssertHasNoPhysicalNames(collaborateur.SourceReference, forbidden);
}
}
[Fact]
public void CollaborateurOmika_UnresolvedCle_KeepsKeyAndWarns()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 9,
COCleCivilite = "INCONNU",
COCleStatut = "AUTRE"
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal("INCONNU", result.Value.Civilite!.Key);
Assert.Equal(string.Empty, result.Value.Civilite!.Label);
Assert.NotNull(result.Value.Statut);
Assert.Equal("AUTRE", result.Value.Statut!.Key);
Assert.Contains(result.Warnings, w => w.Contains("CO_CleCivilite", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
Assert.Contains(result.Warnings, w => w.Contains("CO_CleStatut", StringComparison.Ordinal)
&& w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void CollaborateurOmika_NoCleButLegacyId_FallsBackToLegacyIdAndWarns()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 10,
COIDCivilite = 77,
COIDStatut = 88
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal(77, result.Value.Civilite!.LegacyId);
Assert.NotNull(result.Value.Statut);
Assert.Equal(88, result.Value.Statut!.LegacyId);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
}
[Fact]
public void CollaborateurOmika_NoCleAndZeroLegacyId_ProducesNoReference()
{
var row = new OmikaModels.CollaborateurRow { COPKID = 11 };
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Null(result.Value.Civilite);
Assert.Null(result.Value.Statut);
}
[Fact]
public void CollaborateurOmika_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new OmikaModels.CollaborateurRow
{
COPKID = 12,
COFonction = "Développeur",
COPonderation = 1.5f,
COIDImport = "IMP-12",
COAuPlanning = true,
COAccesBO = true,
COAccesMobile = true,
CreationDate = new DateTime(2024, 1, 1),
ModifDate = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Collaborateur> result = OmikaMappers.OmikaCollaborateurMapper.Map(row);
Assert.Equal("Développeur", result.Value.Fonction);
Assert.Contains(result.IgnoredFields, f => f == "CO_Ponderation");
Assert.Contains(result.IgnoredFields, f => f == "CO_IDImport");
Assert.Contains(result.IgnoredFields, f => f == "CO_AuPlanning");
Assert.Contains(result.IgnoredFields, f => f == "CO_AccesBO");
Assert.Contains(result.IgnoredFields, f => f == "CO_AccesMobile");
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "ModifDate");
Assert.Null(result.Value.SocieteId);
}
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,467 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Omika → canonique des contacts (table Personne).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class OmikaContactMapperTests
{
// ---- 1. Contact Omika minimal --------------------------------------------------
[Fact]
public void ContactOmika_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new OmikaModels.PersonneRow { PEPKID = 1 };
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Civilite);
Assert.Empty(result.Value.Roles);
Assert.Null(result.Value.Coordonnees);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value.SourceReference!.Source);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Contact Omika complet --------------------------------------------------
[Fact]
public void ContactOmika_Full_MapsAllCanonicalProperties()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 42,
GUID = "550e8400-e29b-41d4-a716-446655440000",
PECleCivilite = "MR",
PEIDCivilite = 2,
PENom = "Dupont",
PEPrenom = "Jean",
PECleRole = "R_CP",
PEIDRole = 3,
PETelFixe = "0472000000",
PETelMobile = "0600000000",
PEEmail = "jean.dupont@ville-lyon.fr",
PEFax = "0472000001",
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "Contact principal",
PEActif = true,
PEUsageInterne = false,
PEInfoSupp = "Disponible en journée",
Archive = true,
PEIDImport = "IMP-O-42"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["MR"] = new() { PAPKID = 2, PACLE = "MR", PALibelle = "M.", PACategorie = "CIV" },
["R_CP"] = new() { PAPKID = 3, PACLE = "R_CP", PALibelle = "Contact principal", PACategorie = "ROLE" }
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row, referenceRows);
Contact contact = result.Value;
Assert.Equal("M.", contact.Civilite!.Label);
Assert.Equal("MR", contact.Civilite!.Key);
Assert.Equal(2, contact.Civilite!.LegacyId);
Assert.Equal("Dupont", contact.Nom);
Assert.Equal("Jean", contact.Prenom);
Assert.Equal("Jean Dupont", contact.NomAffiche);
Assert.Single(contact.Roles);
Assert.Equal("Contact principal", contact.Roles[0].Label);
Assert.Equal("R_CP", contact.Roles[0].Key);
Assert.Equal(3, contact.Roles[0].LegacyId);
Assert.Equal("jean.dupont@ville-lyon.fr", contact.Coordonnees!.Email);
Assert.Equal("0472000000", contact.Coordonnees!.Phone);
Assert.Equal("0600000000", contact.Coordonnees!.Mobile);
Assert.Equal("0472000001", contact.Coordonnees!.Fax);
Assert.True(contact.Actif);
Assert.True(contact.EstArchive);
Assert.Equal("Disponible en journée", contact.InformationsSupplementaires);
Assert.Equal(42, contact.SourceReference!.NumericId);
Assert.Equal("IMP-O-42", contact.SourceReference!.ImportId);
Assert.Equal(DatabaseSchemaVersion.Omika, contact.SourceReference!.Source);
Assert.NotNull(contact.SourceReference!.EntityGuid);
}
// ---- 3. GUID valide avec tirets -------------------------------------------------
[Fact]
public void ContactOmika_ValidGuidWithDashes_IsKeptInEntityGuid()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 7,
GUID = "550e8400-e29b-41d4-a716-446655440000",
PENom = "Dupont"
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Equal("550e8400-e29b-41d4-a716-446655440000", result.Value.SourceReference!.PhysicalGuid);
Assert.Empty(result.Warnings);
}
// ---- 4. GUID valide sans tirets -------------------------------------------------
[Fact]
public void ContactOmika_ValidGuidWithoutDashes_IsParsedByTryParse()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 8,
GUID = "550e8400e29b41d4a716446655440000"
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Empty(result.Warnings);
}
// ---- 5. GUID invalide -----------------------------------------------------------
[Fact]
public void ContactOmika_InvalidGuid_ProducesNullGuidAndWarningWithoutException()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 9,
GUID = "not-a-guid",
PENom = "Société inconnue"
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Equal("not-a-guid", result.Value.SourceReference!.PhysicalGuid);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
Assert.Equal("Société inconnue", result.Value.Nom);
}
// ---- 6. Coordonnées entièrement nulles -------------------------------------------
[Fact]
public void ContactOmika_EmptyCoordinates_ProducesNullCoordonnees()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 5,
PENom = "Martin",
PEEmail = " ",
PETelFixe = "",
PETelMobile = null,
PEFax = "\t"
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Null(result.Value.Coordonnees);
Assert.Equal("Martin", result.Value.Nom);
}
// ---- 7. Coordonnées partielles ---------------------------------------------------
[Fact]
public void ContactOmika_PartialCoordinates_KeepsOnlyProvidedValues()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 6,
PEEmail = "m@exemple.fr",
PETelFixe = null,
PETelMobile = "0600000000",
PEFax = ""
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.Coordonnees);
Assert.Equal("m@exemple.fr", result.Value.Coordonnees!.Email);
Assert.Null(result.Value.Coordonnees!.Phone);
Assert.Equal("0600000000", result.Value.Coordonnees!.Mobile);
Assert.Null(result.Value.Coordonnees!.Fax);
}
// ---- 8. Priorité de la clé métier sur l'ancien identifiant ------------------------
[Fact]
public void ContactOmika_BusinessKeyIsPreferredAndLegacyIdIsKeptAsCompatibility()
{
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["MR"] = new() { PAPKID = 50, PACLE = "MR", PALibelle = "M.", PACategorie = "CIV" }
};
var row = new OmikaModels.PersonneRow
{
PEPKID = 13,
PECleCivilite = "MR",
PEIDCivilite = 99 // zombie : ancien identifiant
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row, referenceRows);
Assert.Equal("MR", result.Value.Civilite!.Key);
Assert.Equal("M.", result.Value.Civilite!.Label);
Assert.Equal(99, result.Value.Civilite!.LegacyId);
Assert.Empty(result.Warnings);
}
// ---- 9. Conservation de l'identifiant numérique historique -------------------------
[Fact]
public void ContactOmika_NumericIdentifier_IsPreserved()
{
var row = new OmikaModels.PersonneRow { PEPKID = 1234 };
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
}
// ---- 10. Conservation de l'identifiant d'import -------------------------------------
[Fact]
public void ContactOmika_ImportId_IsPreserved()
{
var row = new OmikaModels.PersonneRow { PEPKID = 5678, PEIDImport = "IMP-O" };
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Equal("IMP-O", result.Value.SourceReference!.ImportId);
Assert.Equal(5678, result.Value.SourceReference!.NumericId);
}
// ---- 11. Absence de GUID fabriqué -----------------------------------------------------
[Fact]
public void ContactOmika_WithoutGuid_ProducesNoFabricatedGuid()
{
var row = new OmikaModels.PersonneRow { PEPKID = 11 };
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
Assert.Empty(result.Warnings);
}
// ---- 12. Repli sur un champ zombie avec avertissement --------------------------------
[Fact]
public void ContactOmika_MissingBusinessKey_ExposesZombieLegacyIdWithWarning()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 14,
PECleCivilite = null, // rubrique active absente
PEIDCivilite = 77 // ancien identifiant (zombie)
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal(77, result.Value.Civilite!.LegacyId);
Assert.Null(result.Value.Civilite!.Key);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
}
// ---- 13. Référentiel non résolu ---------------------------------------------------------
[Fact]
public void ContactOmika_UnresolvedBusinessKey_KeepsKeyAndLegacyIdAndWarns()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 15,
PECleCivilite = "MR",
PEIDCivilite = 77
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value.Civilite);
Assert.Equal("MR", result.Value.Civilite!.Key);
Assert.Equal(77, result.Value.Civilite!.LegacyId);
Assert.Equal(string.Empty, result.Value.Civilite!.Label);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void ContactOmika_ZeroCiviliteAndRole_ProducesNoReference()
{
var row = new OmikaModels.PersonneRow { PEPKID = 16, PEIDCivilite = 0, PEIDRole = 0 };
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Null(result.Value.Civilite);
Assert.Empty(result.Value.Roles);
Assert.Empty(result.Warnings);
}
// ---- 14. Chaînes vides sans exception -----------------------------------------------------
[Fact]
public void ContactOmika_EmptyOrNullStrings_NeverThrow()
{
var row = new OmikaModels.PersonneRow
{
PENom = "",
PEPrenom = " ",
PEEmail = "",
PETelFixe = null,
PETelMobile = " ",
PEFax = "",
PENote = "",
PEInfoSupp = " ",
PEIDImport = "",
GUID = null
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Prenom);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.InformationsSupplementaires);
Assert.Empty(result.Warnings);
}
[Fact]
public void ContactOmika_DefaultRow_NeverThrows()
{
var row = new OmikaModels.PersonneRow();
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.Actif);
Assert.False(result.Value.EstArchive);
}
// ---- 15. Champs sans équivalent dans IgnoredFields ----------------------------------------
[Fact]
public void ContactOmika_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 9,
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "note",
PEUsageInterne = true,
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1),
DateArchivage = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "PE_IDClient");
Assert.Contains(result.IgnoredFields, f => f == "PE_IDAdressePrincipale");
Assert.Contains(result.IgnoredFields, f => f == "PE_Note");
Assert.Contains(result.IgnoredFields, f => f == "PE_UsageInterne");
Assert.Contains(result.IgnoredFields, f => f == "ModifDate");
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
Assert.Null(result.Value.ClientId);
Assert.Empty(result.Value.SiteIds);
}
// ---- 16. Absence de noms physiques HFSQL dans le domaine canonique -------------------------
[Fact]
public void ContactOmika_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new OmikaModels.PersonneRow
{
PEPKID = 40,
GUID = "550e8400-e29b-41d4-a716-446655440000",
PECleCivilite = "MR",
PEIDCivilite = 2,
PENom = "Dupont",
PEPrenom = "Jean",
PECleRole = "R_CP",
PEIDRole = 3,
PEEmail = "j@exemple.fr",
PETelFixe = "01",
PETelMobile = "06",
PEFax = "02",
PEIDClient = 10,
PEIDAdressePrincipale = 20,
PENote = "note",
PEActif = true,
PEUsageInterne = true,
PEInfoSupp = "info",
Archive = false,
PEIDImport = "IMP-40"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["MR"] = new() { PAPKID = 2, PACLE = "MR", PALibelle = "M.", PACategorie = "CIV" },
["R_CP"] = new() { PAPKID = 3, PACLE = "R_CP", PALibelle = "Rôle", PACategorie = "ROLE" }
};
CanonicalMappingResult<Contact> result = OmikaMappers.OmikaContactMapper.Map(row, referenceRows);
Contact contact = result.Value;
string[] forbidden =
[
"PE_", "CL_", "AD_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(contact, forbidden);
if (contact.Coordonnees is not null)
{
AssertHasNoPhysicalNames(contact.Coordonnees, forbidden);
}
if (contact.SourceReference is not null)
{
AssertHasNoPhysicalNames(contact.SourceReference, forbidden);
}
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,301 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.Mapping.Common;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Omika → canonique des interventions.
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
///
/// Le mapper ne renseigne pas <c>SourceReference</c> sur l'entité canonique
/// (la référence source est construite en interne mais non affectée) ; ces
/// tests se concentrent donc sur les propriétés canoniques réellement mappées.
/// </summary>
public class OmikaInterventionMapperTests
{
// ---- 1. Intervention Omika minimale -------------------------------------------
[Fact]
public void InterventionOmika_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new OmikaModels.InterventionRow { INPKID = 1 };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.Reference);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Etat);
Assert.False(result.Value.EstArchive);
Assert.Empty(result.Warnings);
}
// ---- 2. Intervention Omika complète -------------------------------------------
[Fact]
public void InterventionOmika_Full_MapsAllCanonicalProperties()
{
var row = new OmikaModels.InterventionRow
{
INPKID = 42,
INTitre = "Réparation chaudière",
INRefInterne = "INT-042",
INDescription = "Remplacement de la chaudière",
INCleType = "TYP-MAINT",
INCleEtat = "STA-ENCOURS",
INDateIntervention = new DateOnly(2026, 3, 15),
Archive = true,
INIDImport = "IMP-O-42",
GUID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["TYP-MAINT"] = new() { PAPKID = 2, PACLE = "TYP-MAINT", PALibelle = "Maintenance" },
["STA-ENCOURS"] = new() { PAPKID = 3, PACLE = "STA-ENCOURS", PALibelle = "En cours" }
};
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row, referenceRows);
Intervention intervention = result.Value;
Assert.Equal("Réparation chaudière", intervention.Titre);
Assert.Equal("INT-042", intervention.Reference);
Assert.Equal("Remplacement de la chaudière", intervention.Description);
Assert.Equal("Maintenance", intervention.Type!.Label);
Assert.Equal("TYP-MAINT", intervention.Type!.Key);
Assert.Equal("En cours", intervention.Etat!.Label);
Assert.Equal("STA-ENCOURS", intervention.Etat!.Key);
Assert.Equal(new DateTime(2026, 3, 15), intervention.DatePrevueDebut);
Assert.True(intervention.EstArchive);
}
// ---- 3. GUID ------------------------------------------------------------------
[Fact]
public void InterventionOmika_InvalidGuid_ProducesWarning()
{
var row = new OmikaModels.InterventionRow { INPKID = 42, GUID = "not-a-valid-guid" };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
}
[Fact]
public void InterventionOmika_EmptyGuid_NoWarning()
{
var row = new OmikaModels.InterventionRow { INPKID = 42, GUID = "" };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.Empty(result.Warnings);
}
// ---- 4. Type et état (clés métier) --------------------------------------------
[Fact]
public void InterventionOmika_Type_IsResolvedFromCleType()
{
var row = new OmikaModels.InterventionRow { INPKID = 5, INCleType = "TYP-URG" };
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["TYP-URG"] = new() { PAPKID = 7, PACLE = "TYP-URG", PALibelle = "Urgence" }
};
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row, referenceRows);
Assert.NotNull(result.Value.Type);
Assert.Equal("Urgence", result.Value.Type!.Label);
Assert.Equal("TYP-URG", result.Value.Type!.Key);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionOmika_UnresolvedType_KeepsKeyAndWarns()
{
var row = new OmikaModels.InterventionRow { INPKID = 6, INCleType = "TYP-INCONNU" };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.NotNull(result.Value.Type);
Assert.Equal("TYP-INCONNU", result.Value.Type!.Key);
Assert.Equal(string.Empty, result.Value.Type!.Label);
Assert.Contains(result.Warnings, w => w.Contains("IN_CleType", StringComparison.Ordinal));
}
[Fact]
public void InterventionOmika_NoTypeKey_ProducesNoReference()
{
var row = new OmikaModels.InterventionRow { INPKID = 8 };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.Null(result.Value.Type);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionOmika_UnresolvedEtat_KeepsKeyAndWarns()
{
var row = new OmikaModels.InterventionRow { INPKID = 9, INCleEtat = "STA-INCONNU" };
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.NotNull(result.Value.Etat);
Assert.Equal("STA-INCONNU", result.Value.Etat!.Key);
Assert.Contains(result.Warnings, w => w.Contains("IN_CleEtat", StringComparison.Ordinal));
}
// ---- 5. Champs ignorés --------------------------------------------------------
[Fact]
public void InterventionOmika_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new OmikaModels.InterventionRow
{
INPKID = 15,
INIDClient = 1,
INIDLieu = 2,
INIDDossier = 3,
INIDSuiviPar = 4,
INIDDemandeur = 5,
INIDSociete = 6,
INAttestationTVAPresente = true,
INNoteInterne = "note",
INRefExterne = "EXT-1",
INDureePrevue = 2.5f,
INNumDevis = "DEV-1",
INDateDevis = new DateOnly(2026, 1, 1),
INNumFacture = "FAC-1",
INDateFacture = new DateOnly(2026, 2, 1),
INNumIntervention = "N-1",
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1),
DateArchivage = new DateTime(2024, 6, 1)
};
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.Contains(result.IgnoredFields, f => f == "IN_IDClient");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDLieu");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDDossier");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDSuiviPar");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDDemandeur");
Assert.Contains(result.IgnoredFields, f => f == "IN_IDSociete");
Assert.Contains(result.IgnoredFields, f => f == "IN_AttestationTVAPresente");
Assert.Contains(result.IgnoredFields, f => f == "IN_NoteInterne");
Assert.Contains(result.IgnoredFields, f => f == "IN_DureePrevue");
Assert.Contains(result.IgnoredFields, f => f == "IN_DateDevis");
Assert.Contains(result.IgnoredFields, f => f == "IN_DateFacture");
Assert.Equal(new DateTime(2024, 1, 1), result.Value.DateModification);
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
// Champs désormais dotés d'un équivalent canonique : ils ne sont plus
// ignorés, ils sont mappés dans l'entité Intervention.
Assert.Equal("EXT-1", result.Value.ReferenceExterne);
Assert.Equal("DEV-1", result.Value.NumeroDevis);
Assert.Equal("N-1", result.Value.NumeroIntervention);
Assert.Equal("FAC-1", result.Value.NumeroFacture);
}
// ---- 6. Aucune exception sur les chaînes vides --------------------------------
[Fact]
public void InterventionOmika_EmptyOrNullStrings_NeverThrow()
{
var row = new OmikaModels.InterventionRow
{
INTitre = "",
INRefInterne = " ",
INDescription = "",
INNoteInterne = " ",
INRefExterne = "",
INIDImport = " "
};
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Titre);
Assert.Null(result.Value.Reference);
Assert.Null(result.Value.Description);
Assert.Empty(result.Warnings);
}
[Fact]
public void InterventionOmika_DefaultRow_NeverThrows()
{
var row = new OmikaModels.InterventionRow();
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row);
Assert.NotNull(result.Value);
Assert.False(result.Value.EstArchive);
}
// ---- 7. Absence de noms physiques HFSQL dans le domaine canonique -------------
[Fact]
public void InterventionOmika_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new OmikaModels.InterventionRow
{
INPKID = 40,
INTitre = "Titre",
INRefInterne = "INT-040",
INDescription = "desc",
INCleType = "TYP-MAINT",
INCleEtat = "STA-ENCOURS",
INDateIntervention = new DateOnly(2026, 1, 1),
Archive = false,
INIDImport = "IMP-40",
INIDClient = 1,
INIDLieu = 2,
INIDDossier = 3,
INIDSuiviPar = 4,
INIDDemandeur = 5,
INIDSociete = 6,
INAttestationTVAPresente = true,
INNoteInterne = "note",
INRefExterne = "EXT",
INDureePrevue = 1f,
INNumDevis = "DEV",
INNumFacture = "FAC",
INNumIntervention = "N",
GUID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["TYP-MAINT"] = new() { PAPKID = 2, PACLE = "TYP-MAINT", PALibelle = "Maintenance" },
["STA-ENCOURS"] = new() { PAPKID = 3, PACLE = "STA-ENCOURS", PALibelle = "En cours" }
};
CanonicalMappingResult<Intervention> result = OmikaMappers.OmikaInterventionMapper.Map(row, referenceRows);
Intervention intervention = result.Value;
string[] forbidden =
[
"IN_", "PA_", "PKID", "HfSql", "HFSQL"
];
AssertHasNoPhysicalNames(intervention, forbidden);
}
// ---- Helper -------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,258 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Planning;
using Omika.Core.Domain.ValueObjects;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapping Omika de l'affectation Collaborateur d'un
/// rendez-vous (table RDV_Collaborateur). Aucune base réelle : uniquement des
/// objets en mémoire.
/// </summary>
public class OmikaRendezVousMapperTests
{
[Fact]
public void MapCollaborateur_DateFinAnterieureADateDeb_NeLevePasEtPeriodeNull()
{
var row = new OmikaModels.RDVCollaborateurRow
{
RVCPKID = 1,
RVCGUIDColb = "0f8fad5b-d9cb-469f-a165-70867728950e",
RVCDateDeb = new DateTime(2026, 8, 19, 10, 0, 0),
RVCDateFin = new DateTime(2026, 8, 19, 9, 0, 0), // antérieure → anomalie historique
RVCResponsable = true,
RVCTempsValide = true
};
// Aucune DomainValidationException ne doit remonter.
AffectationCollaborateur affectation = OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row);
Assert.NotNull(affectation);
Assert.Null(affectation.PeriodeParticuliere);
// L'affectation elle-même est conservée (identifiant + flags).
Assert.NotNull(affectation.CollaborateurId);
Assert.True(affectation.Responsable);
Assert.True(affectation.TempsValide);
}
[Fact]
public void MapCollaborateur_PlageValide_PeriodeMappee()
{
var debut = new DateTime(2026, 8, 19, 9, 0, 0);
var fin = new DateTime(2026, 8, 19, 10, 0, 0);
var row = new OmikaModels.RDVCollaborateurRow
{
RVCDateDeb = debut,
RVCDateFin = fin
};
AffectationCollaborateur affectation = OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row);
Assert.NotNull(affectation.PeriodeParticuliere);
Assert.Equal(debut, affectation.PeriodeParticuliere!.Start);
Assert.Equal(fin, affectation.PeriodeParticuliere.End);
}
[Fact]
public void MapCollaborateur_DateEgale_PeriodeMappee()
{
var instant = new DateTime(2026, 8, 19, 9, 0, 0);
var row = new OmikaModels.RDVCollaborateurRow
{
RVCDateDeb = instant,
RVCDateFin = instant
};
AffectationCollaborateur affectation = OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row);
Assert.NotNull(affectation.PeriodeParticuliere);
Assert.Equal(instant, affectation.PeriodeParticuliere!.Start);
Assert.Equal(instant, affectation.PeriodeParticuliere.End);
}
[Fact]
public void MapCollaborateur_DatesParDefaut_PeriodeNull()
{
var row = new OmikaModels.RDVCollaborateurRow
{
RVCDateDeb = default,
RVCDateFin = default
};
AffectationCollaborateur affectation = OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row);
Assert.Null(affectation.PeriodeParticuliere);
}
[Fact]
public void MapCollaborateur_PkNumeriqueFourni_SourceReferencePorteNumericIdEtEntityGuid()
{
Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e");
var row = new OmikaModels.RDVCollaborateurRow
{
RVCGUIDColb = guid.ToString("D")
};
AffectationCollaborateur affectation =
OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row, collaborateurNumericId: 42);
Assert.NotNull(affectation.SourceReference);
Assert.Equal(42, affectation.SourceReference!.NumericId);
Assert.Equal(guid, affectation.SourceReference.EntityGuid);
}
[Fact]
public void MapCollaborateur_SansPkNumerique_SourceReferenceSansNumericId()
{
Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e");
var row = new OmikaModels.RDVCollaborateurRow
{
RVCGUIDColb = guid.ToString("D")
};
AffectationCollaborateur affectation =
OmikaMappers.OmikaRendezVousMapper.MapCollaborateur(row);
Assert.NotNull(affectation.SourceReference);
Assert.Null(affectation.SourceReference!.NumericId);
Assert.Equal(guid, affectation.SourceReference.EntityGuid);
}
[Fact]
public void Map_RVIdInterPresent_InterventionSourceReferenceAvecGUID()
{
var interventionGuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 1,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19),
RVIdInter = 105
};
var interventionGuids = new Dictionary<long, Guid> { [105] = interventionGuid };
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, null, interventionGuids);
Assert.NotNull(mapped.Value.InterventionSourceReference);
Assert.Equal(105, mapped.Value.InterventionSourceReference.NumericId);
Assert.Equal(interventionGuid, mapped.Value.InterventionSourceReference.EntityGuid);
}
[Fact]
public void Map_RVIdInterAbsent_InterventionSourceReferenceNull()
{
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 1,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19),
RVIdInter = 0
};
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, null, null);
Assert.Null(mapped.Value.InterventionSourceReference);
}
[Fact]
public void Map_RVIdInterOrphelin_InterventionSourceReferenceSansGUID()
{
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 1,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19),
RVIdInter = 999
};
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, null, new Dictionary<long, Guid>());
Assert.NotNull(mapped.Value.InterventionSourceReference);
Assert.Equal(999, mapped.Value.InterventionSourceReference.NumericId);
Assert.Null(mapped.Value.InterventionSourceReference.EntityGuid);
}
[Fact]
public void Map_AnnulationAvecReferentiel_LibellesOrigineEtMotifResolus()
{
// La fiche du rendez-vous affiche le motif et l'origine d'annulation : sans
// résolution dans le référentiel, elle n'afficherait que le CODE du
// paramètre (« O.ANU.RDV.E.CLI ») au lieu de son libellé.
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 7,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19),
RVCleOrigineAnnu = "O.ANU.RDV.E.CLI",
RVCleMotifAnnu = "M.ANU.RDV.ABS"
};
var references = new Dictionary<string, OmikaModels.ParametreMetierRow>(StringComparer.OrdinalIgnoreCase)
{
["O.ANU.RDV.E.CLI"] = new OmikaModels.ParametreMetierRow
{
PAPKID = 10,
PACLE = "O.ANU.RDV.E.CLI",
PACategorie = "O.ANU.RDV",
PALibelle = "Externe : Client"
},
["M.ANU.RDV.ABS"] = new OmikaModels.ParametreMetierRow
{
PAPKID = 11,
PACLE = "M.ANU.RDV.ABS",
PACategorie = "M.ANU.RDV",
PALibelle = "Absent"
}
};
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, references, null);
Assert.NotNull(mapped.Value.Annulation);
Assert.Equal("O.ANU.RDV.E.CLI", mapped.Value.Annulation!.Origine!.Key);
Assert.Equal("Externe : Client", mapped.Value.Annulation.Origine!.Label);
Assert.Equal("M.ANU.RDV.ABS", mapped.Value.Annulation.Motif!.Key);
Assert.Equal("Absent", mapped.Value.Annulation.Motif!.Label);
}
[Fact]
public void Map_AnnulationSansReferentiel_ConserveLaCleSansInventerDeLibelle()
{
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 8,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19),
RVCleMotifAnnu = "M.ANU.RDV.ABS"
};
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, null, null);
Assert.NotNull(mapped.Value.Annulation);
Assert.Equal("M.ANU.RDV.ABS", mapped.Value.Annulation!.Motif!.Key);
Assert.True(string.IsNullOrEmpty(mapped.Value.Annulation.Motif!.Label));
Assert.Null(mapped.Value.Annulation.Origine);
}
[Fact]
public void Map_SansAnnulation_AucuneAnnulation()
{
var row = new OmikaModels.RDVPlanningRow
{
RVPKID = 9,
GUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
RVDate = new DateOnly(2026, 8, 19)
};
CanonicalMappingResult<RendezVous> mapped =
OmikaMappers.OmikaRendezVousMapper.Map(row, null, null);
Assert.Null(mapped.Value.Annulation);
}
}
@@ -0,0 +1,456 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires du mapper Omika → canonique des sites (table Lieu).
/// Aucune base réelle, aucun HFSQL : uniquement des objets en mémoire.
/// </summary>
public class OmikaSiteMapperTests
{
// ---- 1. Site Omika minimal --------------------------------------------------
[Fact]
public void SiteOmika_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new OmikaModels.LieuRow { LIPKID = 1 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Code);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Adresse);
Assert.Null(result.Value.InformationsComplementaires);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value.SourceReference!.Source);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Site Omika complet --------------------------------------------------
[Fact]
public void SiteOmika_Full_MapsAllCanonicalProperties()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 42,
GUID = "550e8400-e29b-41d4-a716-446655440000",
LICodeLieu = "S-001",
LICleType = "IMM",
LIIDType = 2,
LINote = "Site principal",
Archive = true,
LIIDImport = "IMP-O-42"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["IMM"] = new() { PAPKID = 2, PACLE = "IMM", PALibelle = "Immeuble", PACategorie = "TYPE" }
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row, referenceRows);
Site site = result.Value;
Assert.Equal("S-001", site.Code);
Assert.Equal("Immeuble", site.Type!.Label);
Assert.Equal("IMM", site.Type!.Key);
Assert.Equal(2, site.Type!.LegacyId);
Assert.Equal("Site principal", site.InformationsComplementaires);
Assert.True(site.EstArchive);
Assert.Equal(42, site.SourceReference!.NumericId);
Assert.Equal("IMP-O-42", site.SourceReference!.ImportId);
Assert.Equal(DatabaseSchemaVersion.Omika, site.SourceReference!.Source);
Assert.NotNull(site.SourceReference!.EntityGuid);
}
// ---- 3. GUID valide avec tirets ----------------------------------------------
[Fact]
public void SiteOmika_ValidGuidWithDashes_IsKeptInEntityGuid()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 7,
GUID = "550e8400-e29b-41d4-a716-446655440000",
LICodeLieu = "S-007"
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Equal("550e8400-e29b-41d4-a716-446655440000", result.Value.SourceReference!.PhysicalGuid);
Assert.Empty(result.Warnings);
}
// ---- 4. GUID valide sans tirets ----------------------------------------------
[Fact]
public void SiteOmika_ValidGuidWithoutDashes_IsParsedByTryParse()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 8,
GUID = "550e8400e29b41d4a716446655440000"
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Empty(result.Warnings);
}
// ---- 5. GUID invalide ---------------------------------------------------------
[Fact]
public void SiteOmika_InvalidGuid_ProducesNullGuidAndWarningWithoutException()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 9,
GUID = "not-a-guid",
LICodeLieu = "S-009"
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Equal("not-a-guid", result.Value.SourceReference!.PhysicalGuid);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
Assert.Equal("S-009", result.Value.Code);
}
// ---- 6. Absence de GUID fabriqué ----------------------------------------------
[Fact]
public void SiteOmika_WithoutGuid_ProducesNoFabricatedGuid()
{
var row = new OmikaModels.LieuRow { LIPKID = 11 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Null(result.Value.SourceReference!.PhysicalGuid);
Assert.Empty(result.Warnings);
}
// ---- 7. Conservation de l'identifiant numérique historique ---------------------
[Fact]
public void SiteOmika_NumericIdentifier_IsPreserved()
{
var row = new OmikaModels.LieuRow { LIPKID = 1234 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value.SourceReference!.Source);
Assert.Equal(1234, result.Value.SourceReference!.NumericId);
}
// ---- 8. Conservation de l'identifiant d'import ---------------------------------
[Fact]
public void SiteOmika_ImportId_IsPreserved()
{
var row = new OmikaModels.LieuRow { LIPKID = 5678, LIIDImport = "IMP-O" };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Equal("IMP-O", result.Value.SourceReference!.ImportId);
Assert.Equal(5678, result.Value.SourceReference!.NumericId);
}
// ---- 9. Priorité de la clé métier sur l'ancien identifiant ---------------------
[Fact]
public void SiteOmika_BusinessKeyIsPreferredAndLegacyIdIsKeptAsCompatibility()
{
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["IMM"] = new() { PAPKID = 50, PACLE = "IMM", PALibelle = "Immeuble", PACategorie = "TYPE" }
};
var row = new OmikaModels.LieuRow
{
LIPKID = 13,
LICleType = "IMM",
LIIDType = 99 // zombie : ancien identifiant
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row, referenceRows);
Assert.Equal("IMM", result.Value.Type!.Key);
Assert.Equal("Immeuble", result.Value.Type!.Label);
Assert.Equal(99, result.Value.Type!.LegacyId);
Assert.Empty(result.Warnings);
}
// ---- 10. Repli sur un champ zombie avec avertissement ---------------------------
[Fact]
public void SiteOmika_MissingBusinessKey_ExposesZombieLegacyIdWithWarning()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 14,
LICleType = null, // rubrique active absente
LIIDType = 77 // ancien identifiant (zombie)
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value.Type);
Assert.Equal(77, result.Value.Type!.LegacyId);
Assert.Null(result.Value.Type!.Key);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
}
// ---- 11. Référentiel non résolu -------------------------------------------------
[Fact]
public void SiteOmika_UnresolvedBusinessKey_KeepsKeyAndLegacyIdAndWarns()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 15,
LICleType = "IMM",
LIIDType = 77
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value.Type);
Assert.Equal("IMM", result.Value.Type!.Key);
Assert.Equal(77, result.Value.Type!.LegacyId);
Assert.Equal(string.Empty, result.Value.Type!.Label);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
[Fact]
public void SiteOmika_ZeroType_ProducesNoReference()
{
var row = new OmikaModels.LieuRow { LIPKID = 16, LIIDType = 0 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.Type);
Assert.Empty(result.Warnings);
}
// ---- 12. Adresse déjà chargée en mémoire ---------------------------------------
[Fact]
public void SiteOmika_Adresse_IsMappedFromInMemoryDictionary()
{
var row = new OmikaModels.LieuRow { LIPKID = 17, LIIDAdresse = 20 };
var adresses = new Dictionary<long, OmikaModels.AdresseRow>
{
[20] = new() { ADPKID = 20, ADAdresse = "1 place de la Comédie", ADVille = "Lyon", ADCodePostal = "69001" }
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row, adresses: adresses);
Assert.NotNull(result.Value.Adresse);
Assert.Equal("1 place de la Comédie", result.Value.Adresse!.Line1);
Assert.Equal("69001", result.Value.Adresse!.PostalCode);
Assert.Equal("Lyon", result.Value.Adresse!.City);
}
[Fact]
public void SiteOmika_NoAdresse_WhenNothingProvided()
{
var row = new OmikaModels.LieuRow { LIPKID = 18, LIIDAdresse = 0 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.Adresse);
Assert.Empty(result.Warnings);
}
[Fact]
public void SiteOmika_UnresolvedAdresse_WarnsAndProducesNull()
{
var row = new OmikaModels.LieuRow { LIPKID = 19, LIIDAdresse = 99 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.Adresse);
Assert.Contains(result.Warnings, w => w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
}
// ---- 13. Relations différées ----------------------------------------------------
[Fact]
public void SiteOmika_Relations_AreLeftForLaterAssembly()
{
var row = new OmikaModels.LieuRow { LIPKID = 20, LIIDClientPrincipal = 30 };
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Null(result.Value.ClientId);
Assert.Empty(result.Value.ContactIds);
Assert.Contains(result.IgnoredFields, f => f == "LI_IDClientPrincipal");
}
// ---- 14. Chaînes vides sans exception -------------------------------------------
[Fact]
public void SiteOmika_EmptyOrNullStrings_NeverThrow()
{
var row = new OmikaModels.LieuRow
{
LICodeLieu = "",
LINote = " ",
LIIDImport = "",
LICodeAcces = " ",
LIRaisonBlocage = "",
LIRepertoireDocs = " ",
GUID = null
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Code);
Assert.Null(result.Value.InformationsComplementaires);
Assert.Null(result.Value.SourceReference!.ImportId);
Assert.Empty(result.Warnings);
}
[Fact]
public void SiteOmika_DefaultRow_NeverThrows()
{
var row = new OmikaModels.LieuRow();
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.NotNull(result.Value);
Assert.Null(result.Value.SourceReference!.NumericId);
Assert.False(result.Value.EstArchive);
}
// ---- 15. Champs sans équivalent dans IgnoredFields --------------------------------
[Fact]
public void SiteOmika_FieldsWithoutCanonicalEquivalent_AreReportedAsIgnored()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 21,
LICodeAcces = "Code 1234",
LIBadge = true,
LIInterphone = true,
LIBlocage = true,
LIRaisonBlocage = "Travaux",
LIRepertoireDocs = "docs/site",
LIIDClientPrincipal = 30,
DateArchivage = new DateTime(2024, 6, 1),
ModifDate = new DateTime(2024, 1, 1),
CreationDate = new DateTime(2023, 1, 1),
CreationUser = 5,
ModifUser = 6
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row);
Assert.Equal("Code 1234", result.Value.CodeAcces);
Assert.True(result.Value.Badge);
Assert.True(result.Value.Interphone);
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_CodeAcces");
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_Badge");
Assert.DoesNotContain(result.IgnoredFields, f => f == "LI_Interphone");
Assert.Contains(result.IgnoredFields, f => f == "LI_Blocage");
Assert.Contains(result.IgnoredFields, f => f == "LI_RaisonBlocage");
Assert.Contains(result.IgnoredFields, f => f == "LI_RepertoireDocs");
Assert.Contains(result.IgnoredFields, f => f == "LI_IDClientPrincipal");
Assert.Contains(result.IgnoredFields, f => f == "DateArchivage");
Assert.Equal(new DateTime(2024, 1, 1), result.Value.DateModification);
Assert.Contains(result.IgnoredFields, f => f == "CreationDate");
Assert.Contains(result.IgnoredFields, f => f == "CreationUser");
Assert.Contains(result.IgnoredFields, f => f == "ModifUser");
}
// ---- 16. Absence de noms physiques HFSQL dans le domaine canonique ----------------
[Fact]
public void SiteOmika_CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new OmikaModels.LieuRow
{
LIPKID = 40,
GUID = "550e8400-e29b-41d4-a716-446655440000",
LICodeLieu = "S-040",
LICleType = "IMM",
LIIDType = 2,
LINote = "note",
Archive = false,
LIIDImport = "IMP-40",
LIIDAdresse = 20,
LIIDClientPrincipal = 30,
LICodeAcces = "code",
LIBadge = true,
LIInterphone = true,
LIBlocage = true,
LIRaisonBlocage = "raison",
LIRepertoireDocs = "docs"
};
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["IMM"] = new() { PAPKID = 2, PACLE = "IMM", PALibelle = "Immeuble", PACategorie = "TYPE" }
};
var adresses = new Dictionary<long, OmikaModels.AdresseRow>
{
[20] = new() { ADPKID = 20, ADAdresse = "1 rue de la Paix", ADVille = "Lyon", ADCodePostal = "69001" }
};
CanonicalMappingResult<Site> result = OmikaMappers.OmikaSiteMapper.Map(row, referenceRows, adresses);
Site site = result.Value;
string[] forbidden =
[
"LI_", "AD_", "PA_", "PKID", "HfSql", "HFSQL", "GUID"
];
AssertHasNoPhysicalNames(site, forbidden);
if (site.Adresse is not null)
{
AssertHasNoPhysicalNames(site.Adresse, forbidden);
}
if (site.SourceReference is not null)
{
AssertHasNoPhysicalNames(site.SourceReference, forbidden);
}
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,495 @@
using Omika.Core.Compatibility;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Domain.ValueObjects;
using Omika.Data.Mapping.Common;
using BatikModels = Omika.Data.HfSql.Models.Batik;
using OmikaModels = Omika.Data.HfSql.Models.Omika;
using BatikMappers = Omika.Data.Mapping.Batik;
using OmikaMappers = Omika.Data.Mapping.Omika;
namespace Omika.Tests.Mapping;
/// <summary>
/// Tests unitaires des premiers mappers physiques → canonique (Clients,
/// Adresses et valeurs de référentiel). Aucune base réelle, aucun HFSQL :
/// uniquement des objets en mémoire.
/// </summary>
public class PhysicalToCanonicalMappingTests
{
// ---- 1. Client Batik minimal -------------------------------------------------
[Fact]
public void ClientBatik_Minimal_MapsWithoutExceptionAndKeepsNoFabricatedValue()
{
var row = new BatikModels.ClientRow { CLPKID = 1 };
CanonicalMappingResult<Client> result = BatikMappers.BatikClientMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.NotNull(result.Value);
Assert.Null(result.Value.Nom);
Assert.Null(result.Value.Type);
Assert.Null(result.Value.Statut);
Assert.Null(result.Value.Coordonnees);
Assert.Null(result.Value.AdressePrincipale);
Assert.False(result.Value.EstArchive);
Assert.NotNull(result.Value.SourceReference);
Assert.Equal(1, result.Value.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value.SourceReference!.Source);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Empty(result.Warnings);
}
// ---- 2. Client Batik complet -------------------------------------------------
[Fact]
public void ClientBatik_Full_MapsAllCanonicalProperties()
{
var row = new BatikModels.ClientRow
{
CLPKID = 42,
CLCodeClient = "C-001",
CLNom = "Ville de Lyon",
CLIDType = 2,
CLIDStatut = 3,
CLFormeJuridique = "Collectivité",
CLCodeTVA = "FR12345678901",
CLCodeComptable = "411LYON",
CLSIREN = "123456789",
CLSIRET = "12345678900012",
CLEmail = "contact@ville-lyon.fr",
CLTel = "0472000000",
CLFax = "0472000001",
CLPays = "FR",
CLInfoSup = "Client principal",
CLBlocage = true,
ClRaisonBlocage = "Litige en cours",
Archive = true,
CLIDAdressePrincipale = 10
};
var referenceRows = new Dictionary<long, BatikModels.ParametreMetierRow>
{
[2] = new() { PAPKID = 2, PACode = "T", PAValeur = "1", PALibelle = "Régie" },
[3] = new() { PAPKID = 3, PACode = "S", PAValeur = "1", PALibelle = "Actif" }
};
var adresses = new Dictionary<long, BatikModels.AdresseRow>
{
[10] = new() { ADPKID = 10, ADEtiquette = "Mairie", ADLigne1 = "1 place de la Comédie", ADVille = "Lyon", ADCodePostal = "69001" }
};
CanonicalMappingResult<Client> result = BatikMappers.BatikClientMapper.Map(row, referenceRows, adresses);
Client client = result.Value;
Assert.Equal("C-001", client.Code);
Assert.Equal("Ville de Lyon", client.Nom);
Assert.Equal("Régie", client.Type!.Label);
Assert.Equal(2, client.Type!.LegacyId);
Assert.Equal("T", client.Type!.Code);
Assert.Equal("Actif", client.Statut!.Label);
Assert.Equal(3, client.Statut!.LegacyId);
Assert.Equal("Collectivité", client.FormeJuridique!.Label);
Assert.Equal("FR12345678901", client.CodeTva);
Assert.Equal("411LYON", client.CodeComptable);
Assert.Equal("123456789", client.Siren);
Assert.Equal("12345678900012", client.Siret);
Assert.Equal("contact@ville-lyon.fr", client.Coordonnees!.Email);
Assert.Equal("0472000000", client.Coordonnees!.Phone);
Assert.Equal("0472000001", client.Coordonnees!.Fax);
Assert.Equal("FR", client.Pays);
Assert.Equal("Client principal", client.InformationsComplementaires);
Assert.True(client.EstBloque);
Assert.Equal("Litige en cours", client.RaisonBlocage);
Assert.True(client.EstArchive);
Assert.NotNull(client.AdressePrincipale);
Assert.True(client.AdressePrincipale!.EstPrincipale);
Assert.Equal("Mairie", client.AdressePrincipale!.Etiquette);
Assert.Equal("69001", client.AdressePrincipale!.AdressePostale!.PostalCode);
Assert.Equal(42, client.SourceReference!.NumericId);
Assert.Equal(DatabaseSchemaVersion.Batik, client.SourceReference!.Source);
}
// ---- 3. Adresse Batik ---------------------------------------------------------
[Fact]
public void AdresseBatik_MapsLinesAndFillsLine3FromLine4Line5WhenEmpty()
{
var complete = new BatikModels.AdresseRow
{
ADPKID = 5,
ADEtiquette = "Siège",
ADLigne1 = "M. Dupont",
ADLigne2 = "Service comptabilité",
ADLigne3 = "Bâtiment A",
ADLigne4 = "12 rue de la Paix",
ADLigne5 = "ZI Nord",
ADCodePostal = "69001",
ADVille = "Lyon",
ADPresentation = "bloc courrier"
};
CanonicalMappingResult<Adresse> result = BatikMappers.BatikAdresseMapper.Map(complete);
Adresse adresse = result.Value;
Assert.Equal(DatabaseSchemaVersion.Batik, result.SourceVersion);
Assert.Equal("Siège", adresse.Etiquette);
Assert.Equal("M. Dupont", adresse.AdressePostale!.Line1);
Assert.Equal("Service comptabilité", adresse.AdressePostale!.Line2);
Assert.Equal("Bâtiment A", adresse.AdressePostale!.Line3);
Assert.Equal("69001", adresse.AdressePostale!.PostalCode);
Assert.Equal("Lyon", adresse.AdressePostale!.City);
Assert.Equal(5, adresse.SourceReference!.NumericId);
Assert.Contains(result.IgnoredFields, f => f == "AD_Presentation");
var withoutLine3 = new BatikModels.AdresseRow
{
ADPKID = 6,
ADLigne1 = "M. Dupont",
ADLigne4 = "12 rue de la Paix",
ADLigne5 = "ZI Nord",
ADCodePostal = "69001",
ADVille = "Lyon"
};
CanonicalMappingResult<Adresse> result2 = BatikMappers.BatikAdresseMapper.Map(withoutLine3);
Assert.Equal("12 rue de la Paix ZI Nord", result2.Value.AdressePostale!.Line3);
}
// ---- 4. Client Omika avec GUID valide ----------------------------------------
[Fact]
public void ClientOmika_ValidGuid_KeepsEntityGuidAndPhysicalGuid()
{
var row = new OmikaModels.ClientRow
{
CLPKID = 7,
GUID = "550e8400-e29b-41d4-a716-446655440000",
CLNom = "SARL Omika"
};
CanonicalMappingResult<Client> result = OmikaMappers.OmikaClientMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Equal("550e8400-e29b-41d4-a716-446655440000", result.Value.SourceReference!.PhysicalGuid);
Assert.Equal(7, result.Value.SourceReference!.NumericId);
Assert.Equal("SARL Omika", result.Value.Nom);
}
// ---- 5. Client Omika avec GUID sans tirets ------------------------------------
[Fact]
public void ClientOmika_GuidWithoutDashes_IsParsedByTryParse()
{
var row = new OmikaModels.ClientRow
{
CLPKID = 8,
GUID = "550e8400e29b41d4a716446655440000"
};
CanonicalMappingResult<Client> result = OmikaMappers.OmikaClientMapper.Map(row);
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
Assert.Equal(
"550e8400-e29b-41d4-a716-446655440000",
result.Value.SourceReference!.EntityGuid!.Value.ToString("D"));
Assert.Empty(result.Warnings);
}
// ---- 6. Client Omika avec GUID invalide ---------------------------------------
[Fact]
public void ClientOmika_InvalidGuid_ProducesNullGuidAndWarningWithoutException()
{
var row = new OmikaModels.ClientRow
{
CLPKID = 9,
GUID = "not-a-guid",
CLNom = "Société inconnue"
};
CanonicalMappingResult<Client> result = OmikaMappers.OmikaClientMapper.Map(row);
Assert.Null(result.Value.SourceReference!.EntityGuid);
Assert.Equal("not-a-guid", result.Value.SourceReference!.PhysicalGuid);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
Assert.Equal("Société inconnue", result.Value.Nom);
}
// ---- 7. Adresse Omika ----------------------------------------------------------
[Fact]
public void AdresseOmika_NativeMemoIsPreferredOverZombieLines()
{
var row = new OmikaModels.AdresseRow
{
ADPKID = 11,
GUID = "1f0e8400-e29b-41d4-a716-446655441111",
ADEtiquette = "Agence Lyon",
ADAdresse = "12 rue de la Paix",
ADLigne1 = "ancienne ligne 1",
ADCodePostal = "69001",
ADVille = "Lyon"
};
CanonicalMappingResult<Adresse> result = OmikaMappers.OmikaAdresseMapper.Map(row);
Assert.Equal(DatabaseSchemaVersion.Omika, result.SourceVersion);
Assert.Equal("12 rue de la Paix", result.Value.AdressePostale!.Line1);
Assert.Equal("Agence Lyon", result.Value.Etiquette);
Assert.Equal("69001", result.Value.AdressePostale!.PostalCode);
Assert.Equal("Lyon", result.Value.AdressePostale!.City);
Assert.DoesNotContain(result.Warnings, w => w.StartsWith(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
Assert.NotNull(result.Value.SourceReference!.EntityGuid);
}
[Fact]
public void AdresseOmika_WithoutNativeMemo_UsesZombieLinesAsDocumentedFallback()
{
var row = new OmikaModels.AdresseRow
{
ADPKID = 12,
ADLigne1 = "M. Dupont",
ADLigne2 = "Service comptabilité",
ADCodePostal = "69002",
ADVille = "Lyon"
};
CanonicalMappingResult<Adresse> result = OmikaMappers.OmikaAdresseMapper.Map(row);
Assert.Equal("M. Dupont", result.Value.AdressePostale!.Line1);
Assert.Equal("Service comptabilité", result.Value.AdressePostale!.Line2);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
}
// ---- 8. Priorité de la clé métier Omika ----------------------------------------
[Fact]
public void ClientOmika_BusinessKeyIsPreferredAndLegacyIdIsKeptAsCompatibility()
{
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["ENT"] = new() { PAPKID = 50, PACLE = "ENT", PALibelle = "Entreprise", PACategorie = "TYPE" }
};
var row = new OmikaModels.ClientRow
{
CLPKID = 13,
CLCleType = "ENT",
CLIDType = 99, // zombie : ancien identifiant
CLCleStatut = null,
CLIDStatut = 5 // zombie : ancien identifiant, pas de clé métier
};
CanonicalMappingResult<Client> result = OmikaMappers.OmikaClientMapper.Map(row, referenceRows);
Assert.Equal("ENT", result.Value.Type!.Key);
Assert.Equal("Entreprise", result.Value.Type!.Label);
Assert.Equal(99, result.Value.Type!.LegacyId);
Assert.NotNull(result.Value.Statut);
Assert.Equal(5, result.Value.Statut!.LegacyId);
Assert.Null(result.Value.Statut!.Key);
Assert.Contains(result.Warnings, w => w.StartsWith(MappingWarningCodes.ZombieFieldUsedAsFallback, StringComparison.Ordinal));
}
// ---- 9. Conservation des identifiants sources ----------------------------------
[Fact]
public void SourceIdentifiers_ArePreservedOnBothSchemas()
{
var batik = new BatikModels.ClientRow { CLPKID = 1234, CLIDImport = "IMP-B" };
CanonicalMappingResult<Client> batikResult = BatikMappers.BatikClientMapper.Map(batik);
Assert.Equal(DatabaseSchemaVersion.Batik, batikResult.Value.SourceReference!.Source);
Assert.Equal(1234, batikResult.Value.SourceReference!.NumericId);
Assert.Equal("IMP-B", batikResult.Value.SourceReference!.ImportId);
Assert.Null(batikResult.Value.SourceReference!.EntityGuid);
var omika = new OmikaModels.ClientRow
{
CLPKID = 5678,
GUID = "550e8400-e29b-41d4-a716-446655440000",
CLIDImport = "IMP-O"
};
CanonicalMappingResult<Client> omikaResult = OmikaMappers.OmikaClientMapper.Map(omika);
Assert.Equal(DatabaseSchemaVersion.Omika, omikaResult.Value.SourceReference!.Source);
Assert.Equal(5678, omikaResult.Value.SourceReference!.NumericId);
Assert.Equal("IMP-O", omikaResult.Value.SourceReference!.ImportId);
Assert.NotNull(omikaResult.Value.SourceReference!.EntityGuid);
}
// ---- 10. Aucune exception sur les valeurs historiques ou nulles ----------------
[Fact]
public void Mapping_HistoricalOrNullValues_NeverThrow()
{
var batikClient = new BatikModels.ClientRow();
BatikMappers.BatikClientMapper.Map(batikClient);
var batikAdresse = new BatikModels.AdresseRow();
BatikMappers.BatikAdresseMapper.Map(batikAdresse);
var batikRef = new BatikModels.ParametreMetierRow();
BatikMappers.BatikReferenceValueMapper.Map(batikRef);
var omikaClient = new OmikaModels.ClientRow();
OmikaMappers.OmikaClientMapper.Map(omikaClient);
var omikaAdresse = new OmikaModels.AdresseRow();
OmikaMappers.OmikaAdresseMapper.Map(omikaAdresse);
var omikaRef = new OmikaModels.ParametreMetierRow();
OmikaMappers.OmikaReferenceValueMapper.Map(omikaRef);
var omikaClientLegacy = new OmikaModels.ClientRow { CLIDType = 12, CLIDStatut = 0 };
CanonicalMappingResult<Client> legacyResult = OmikaMappers.OmikaClientMapper.Map(omikaClientLegacy);
Assert.NotNull(legacyResult.Value.Type);
Assert.Equal(12, legacyResult.Value.Type!.LegacyId);
var omikaClientNullGuid = new OmikaModels.ClientRow { GUID = null! };
Assert.NotNull(OmikaMappers.OmikaClientMapper.Map(omikaClientNullGuid).Value);
}
// ---- 11. Présence des avertissements attendus ----------------------------------
[Fact]
public void Warnings_AreRaisedForInvalidGuidAndUnresolvedReference()
{
// GUID invalide.
var invalidGuid = new OmikaModels.ClientRow { CLPKID = 21, GUID = "zzz" };
CanonicalMappingResult<Client> invalidGuidResult = OmikaMappers.OmikaClientMapper.Map(invalidGuid);
Assert.Contains(invalidGuidResult.Warnings, w => w.StartsWith(MappingWarningCodes.InvalidPhysicalGuid, StringComparison.Ordinal));
// Référentiel Batik non résolu.
var unresolved = new BatikModels.ClientRow { CLPKID = 22, CLIDType = 77 };
CanonicalMappingResult<Client> unresolvedResult = BatikMappers.BatikClientMapper.Map(unresolved);
Assert.Contains(unresolvedResult.Warnings, w => w.Contains(MappingWarningCodes.UnresolvedReference, StringComparison.Ordinal));
// Clé métier absente sur une valeur de référentiel Omika.
var noKey = new OmikaModels.ParametreMetierRow { PAPKID = 30, PALibelle = "Actif" };
CanonicalMappingResult<ReferenceValue> noKeyResult = OmikaMappers.OmikaReferenceValueMapper.Map(noKey);
Assert.Contains(noKeyResult.Warnings, w => w.StartsWith(MappingWarningCodes.MissingBusinessKey, StringComparison.Ordinal));
Assert.Equal("Actif", noKeyResult.Value.Label);
// Libellé absent : jamais inventé, simple avertissement.
var noLabel = new BatikModels.ParametreMetierRow { PAPKID = 31, PACode = "T", PAValeur = "1" };
CanonicalMappingResult<ReferenceValue> noLabelResult = BatikMappers.BatikReferenceValueMapper.Map(noLabel);
Assert.Contains(noLabelResult.Warnings, w => w.StartsWith(MappingWarningCodes.EmptyReferenceLabel, StringComparison.Ordinal));
Assert.Equal(string.Empty, noLabelResult.Value.Label);
Assert.Equal(31, noLabelResult.Value.LegacyId);
}
// ---- 12. Absence de noms HFSQL dans le domaine canonique ------------------------
[Fact]
public void CanonicalDomain_DoesNotExposeHfSqlNames()
{
var row = new OmikaModels.ClientRow
{
CLPKID = 40,
GUID = "550e8400-e29b-41d4-a716-446655440000",
CLNom = "SARL Test",
CLIDType = 0,
CLCleType = null,
CLIDStatut = 0,
CLCodeTVA = "FR123",
CLEmail = "test@test.fr",
Archive = false
};
CanonicalMappingResult<Client> result = OmikaMappers.OmikaClientMapper.Map(row);
Client client = result.Value;
string[] forbidden =
[
"CL_", "AD_", "PA_", "PKID", "HfSql", "HFSQL", "Ligne_", "GUID"
];
AssertHasNoPhysicalNames(client, forbidden);
AssertHasNoPhysicalNames(client.AdressePrincipale ?? new Adresse(), forbidden);
if (client.Coordonnees is not null)
{
AssertHasNoPhysicalNames(client.Coordonnees, forbidden);
}
if (client.SourceReference is not null)
{
AssertHasNoPhysicalNames(client.SourceReference, forbidden);
}
}
// ---- Référentiels ----------------------------------------------------------------
[Fact]
public void BatikReferenceValue_MapFavorsLegacyIdCodeAndLabel()
{
var row = new BatikModels.ParametreMetierRow
{
PAPKID = 60,
PACode = "T",
PAValeur = "1",
PALibelle = "Régie"
};
CanonicalMappingResult<ReferenceValue> result = BatikMappers.BatikReferenceValueMapper.Map(row);
Assert.Equal("Régie", result.Value.Label);
Assert.Equal(60, result.Value.LegacyId);
Assert.Equal("T", result.Value.Code);
Assert.Null(result.Value.Key);
}
[Fact]
public void OmikaReferenceValue_MapFavorsBusinessKeyAndKeepsLegacyIdAsCompatibility()
{
var row = new OmikaModels.ParametreMetierRow
{
PAPKID = 61,
PACLE = "ACT",
PALibelle = "Actif",
PACategorie = "STATUT"
};
CanonicalMappingResult<ReferenceValue> result = OmikaMappers.OmikaReferenceValueMapper.Map(row);
Assert.Equal("Actif", result.Value.Label);
Assert.Equal("ACT", result.Value.Key);
Assert.Equal(61, result.Value.LegacyId);
Assert.Equal("STATUT", result.Value.Code);
}
[Fact]
public void OmikaReferenceValue_MapByKey_UsesInMemoryDictionary()
{
var referenceRows = new Dictionary<string, OmikaModels.ParametreMetierRow>
{
["RGE"] = new() { PAPKID = 70, PACLE = "RGE", PALibelle = "Régie", PACategorie = "TYPE" }
};
CanonicalMappingResult<ReferenceValue>? resolved = OmikaMappers.OmikaReferenceValueMapper.MapByKey("RGE", referenceRows);
Assert.NotNull(resolved);
Assert.Equal("Régie", resolved!.Value.Label);
Assert.Equal("RGE", resolved.Value.Key);
}
// ---- Helper ---------------------------------------------------------------------
private static void AssertHasNoPhysicalNames(object value, string[] forbidden)
{
foreach (var property in value.GetType().GetProperties())
{
Assert.DoesNotContain(forbidden, f => property.Name.Contains(f, StringComparison.Ordinal));
}
}
}
@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Threading;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.VisualTree;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Régression : l'agenda personnel n'affiche plus les trois blocs statistiques
/// (« Rendez-vous », « Liés à une intervention », « Autonomes »). Ces compteurs
/// faisaient doublon avec le bandeau de la journée et alourdissaient l'écran.
/// </summary>
/// <remarks>
/// Le test ouvre la vue RÉELLE et parcourt l'arbre visuel : il ne dépend ni des
/// données ni des services (aucun ViewModel n'est nécessaire, les libellés
/// vérifiés sont écrits en dur dans le XAML). Il rejoint la collection
/// « ApplicationRuntimeContext » car le localisateur d'Avalonia est global au
/// processus.
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class AgendaSansCartesStatistiquesTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
[Fact]
public void AgendaPersonnel_N_AffichePlusLesTroisBlocsStatistiques()
{
_session.Dispatch(
() =>
{
Window fenetre = MesureGeometrique.Ouvrir(new Omika.App.Views.AgendaView(), 360);
Control[] descendants = fenetre.GetVisualDescendants().OfType<Control>().ToArray();
// 1. Aucune carte statistique (classe de style dédiée).
string[] cartes = descendants
.OfType<Border>()
.Where(b => b.Classes.Contains("statCard"))
.Select(_ => "statCard")
.ToArray();
Assert.Empty(cartes);
// 2. Aucun des libellés des trois blocs supprimés.
string[] textes = descendants
.OfType<TextBlock>()
.Where(t => !string.IsNullOrWhiteSpace(t.Text))
.Select(t => t.Text!)
.ToArray();
Assert.DoesNotContain("Rendez-vous", textes);
Assert.DoesNotContain("Liés à une intervention", textes);
Assert.DoesNotContain("Autonomes", textes);
// 3. Le reste de la page est intact : bandeau de la journée et
// sélecteur de mode.
Assert.Contains("Votre journée", textes);
Assert.Contains("Jour", textes);
Assert.Contains("Semaine", textes);
Assert.Contains("Calendrier", textes);
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
}
@@ -0,0 +1,407 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Local.Abstractions;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.Sync;
using SkiaSharp;
using Xunit;
using Path = Avalonia.Controls.Shapes.Path;
namespace Omika.Tests.Mobile;
/// <summary>
/// Régression mesurée : dans le menu bas principal, l'icône du bouton « Sites »
/// (repère Material de 14×20) n'était pas centrée. Les tracés sont dessinés dans
/// un gabarit 24×24 et centrés en (12 ; 12) ; rendus en 18×18 avec
/// <c>Stretch="Uniform"</c>, Avalonia ramenait les limites du tracé sur le coin
/// haut-gauche de la boîte et laissait les 5 px de jeu à droite : le repère
/// sortait <b>2,5 px à gauche</b> de son bouton (les autres icônes étaient
/// décalées de 0 à 3 px selon leur forme).
/// </summary>
/// <remarks>
/// Ces tests mesurent le rendu Skia RÉEL du Shell complet (barre basse et rail
/// latéral), icône par icône : aucune hypothèse sur la géométrie, sur
/// <c>Stretch</c> ni sur les styles. Ils rejoignent la collection
/// « ApplicationRuntimeContext » car le localisateur d'Avalonia est global au
/// processus.
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class BarreNavigationIconesTests : IDisposable
{
/// <summary>Largeur d'un petit téléphone : sous 600 px, les libellés sont masqués.</summary>
private const double LargeurTelephoneEtroit = 360;
/// <summary>Largeur desktop : au-dessus de 960 px, c'est le rail latéral qui est affiché.</summary>
private const double LargeurBureau = 1200;
/// <summary>Très petit téléphone : tout doit rester dans l'écran.</summary>
private const double LargeurPetitEcran = 320;
/// <summary>Largeur au-dessus de laquelle les libellés de la barre basse sont affichés.</summary>
private const double LargeurLibelles = 700;
/// <summary>Tolérance (px) : l'encre est comptée en pixels entiers.</summary>
private const double Tolerance = 1.0;
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
[Fact]
public void BarreBasse_ChaqueIcone_EstCentreeDansSonBouton()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "barre basse", Disposition.IconeSeuleDansLeBouton);
}
}, LargeurTelephoneEtroit);
/// <summary>
/// Au-dessus de 600 px, les libellés apparaissent : le groupe icône + libellé
/// doit rester centré dans le bouton et la barre entière doit tenir dans
/// l'écran (le rembourrage des boutons ne doit pas la faire déborder).
/// </summary>
[Fact]
public void BarreBasse_LibellesAffiches_LeGroupeResteCentreEtDansLEcran()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "barre basse (libellés)", Disposition.IconeAuDessusDuLibelle);
}
double droite = boutons.Max(b => b.TranslatePoint(new Point(b.Bounds.Width, 0), fenetre)!.Value.X);
Assert.True(
droite <= LargeurLibelles + 0.5,
$"La barre basse dépasse l'écran de {droite - LargeurLibelles:0.#} px " +
$"à {LargeurLibelles} px.");
}, LargeurLibelles);
/// <summary>
/// Rail latéral : l'icône est en tête de ligne, suivie du libellé — elle n'est
/// donc pas centrée dans le bouton (pleine largeur), mais elle doit y être
/// dessinée à sa taille et à sa position natives, comme dans la barre basse.
/// </summary>
[Fact]
public void RailLateral_ChaqueIcone_EstRendueEnTailleNative()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "navItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "rail latéral", Disposition.IconeAvantLeLibelle);
}
}, LargeurBureau);
/// <summary>
/// Sur un très petit écran (320 px), les sept boutons de la barre basse
/// (icônes de 24 px, libellés masqués) doivent tous tenir dans la largeur,
/// sans être rognés : le panneau répartit l'espace restant, il ne réduit
/// jamais les boutons.
/// </summary>
[Fact]
public void BarreBasse_TousLesBoutons_TiennentDansLaLargeur_AEcran320()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
double droite = boutons.Max(b => b.TranslatePoint(new Point(b.Bounds.Width, 0), fenetre)!.Value.X);
string detail = string.Join(
", ",
boutons.Select(b => $"{b.CommandParameter}={b.Bounds.Width:0.#}px"));
Assert.True(
droite <= LargeurPetitEcran + 0.5,
$"La barre basse dépasse l'écran de {droite - LargeurPetitEcran:0.#} px " +
$"à {LargeurPetitEcran} px (boutons : {detail}).");
}, LargeurPetitEcran);
/// <summary>
/// Le rendu en taille native n'est possible que si chaque tracé tient dans le
/// gabarit carré 24×24 : un tracé plus grand serait rogné (et le centrage
/// perdu). Ce test verrouille la contrainte pour tout futur ajout d'icône.
/// </summary>
[Fact]
public void IconesDeNavigation_TiennentDansLeGabaritCarre24x24()
=> ExecuterSansVue(() =>
{
foreach (NavigationItem item in new NavigationService().Items)
{
Geometry geo = Geometrie(item.Icon);
Rect limites = geo.Bounds;
Assert.True(
limites.X >= 0 && limites.Y >= 0 && limites.Right <= 24 && limites.Bottom <= 24,
$"L'icône « {item.Icon} » dépasse le gabarit 24×24 : {limites} — " +
"elle serait rognée en taille native (Stretch=\"None\").");
}
});
// ===== Outils =====
/// <summary>Exécute des vérifications sur le thread d'interface, sans ouvrir de vue.</summary>
private void ExecuterSansVue(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>Construit le Shell réel et exécute les vérifications sur le rendu.</summary>
private void Executer(Action<Window, IReadOnlyList<NavigationItem>> scenario, double largeur)
{
_session.Dispatch(
() =>
{
var permissions = new PermissionService();
permissions.SetRights(MobilePermissionKeys.All.ToDictionary(k => k, _ => true));
var navigation = new NavigationService(permissions);
var shell = new ShellViewModel(
navigation,
null!,
new FakeSyncEngine(),
new FakePageFactory(),
null!,
permissions,
new FakeOutboxService(),
null!);
Window fenetre = MesureGeometrique.Ouvrir(new ShellView { DataContext = shell }, largeur);
scenario(fenetre, navigation.Items);
fenetre.Close();
// Le Shell déclenche des lectures locales en tâche de fond : on
// vide la file d'attente avant de rendre la main, pour que la
// session headless se termine sans travail en suspens.
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
/// <summary>Boutons visibles de la barre basse (ou du rail latéral).</summary>
private static Button[] Boutons(Window fenetre, string classe)
=> fenetre.GetVisualDescendants()
.OfType<Button>()
.Where(b => b.Classes.Contains(classe) && b.IsVisible && b.Bounds.Width > 0)
.ToArray();
/// <summary>
/// Vérifie, sur le rendu réel, que le tracé est dessiné <b>à sa taille et à sa
/// position natives</b> dans la boîte du <c>Path</c> (c'est-à-dire sans
/// décalage ni mise à l'échelle cachée), puis le placement attendu selon la
/// disposition de l'élément de navigation.
/// </summary>
private static void VerifierIcone(
Button bouton,
IReadOnlyList<NavigationItem> items,
string zone,
Disposition disposition)
{
var page = (AppPage)bouton.CommandParameter!;
NavigationItem item = items.Single(i => i.Page == page);
Path chemin = bouton.GetVisualDescendants().OfType<Path>().Single();
int cote = (int)Math.Round(chemin.Bounds.Width);
int hauteur = (int)Math.Round(chemin.Bounds.Height);
Assert.True(cote > 0 && hauteur > 0, $"L'icône « {item.Icon} » ({zone}) n'a pas été disposée.");
using var cible = new RenderTargetBitmap(new PixelSize(cote, hauteur), new Vector(96, 96));
cible.Render(chemin);
using var flux = new MemoryStream();
cible.Save(flux);
using var bitmap = SKBitmap.Decode(flux.ToArray());
(int x0, int y0, int x1, int y1) = EncreDe(bitmap);
Assert.True(x1 >= x0, $"Aucune encre rendue pour l'icône « {item.Icon} » ({zone}).");
string detail =
$"« {item.Icon} » ({zone}) : encre=[{x0},{y0} .. {x1},{y1}] dans une boîte {cote}x{hauteur}";
// 1. Taille et position natives : l'encre doit occuper exactement les
// limites du tracé, à l'échelle 1:1 depuis l'origine de la boîte. Un
// Stretch="Uniform" (même partiel) ou une boîte mal dimensionnée
// déplace ou rétrécit l'encre : c'est le défaut mesuré à l'origine
// (repère « site » sorti 2,5 px à gauche).
Rect attendu = Geometrie(item.Icon).Bounds;
Assert.True(
Math.Abs(x0 - attendu.X) <= Tolerance
&& Math.Abs(y0 - attendu.Y) <= Tolerance
&& Math.Abs(x1 + 1 - attendu.Right) <= Tolerance
&& Math.Abs(y1 + 1 - attendu.Bottom) <= Tolerance,
$"L'icône « {item.Icon} » ({zone}) n'est pas dessinée à sa place native " +
$"(attendu [{attendu.X:0.#},{attendu.Y:0.#} .. {attendu.Right:0.#},{attendu.Bottom:0.#}]) — {detail}.");
Point? origine = chemin.TranslatePoint(new Point(0, 0), bouton);
Assert.NotNull(origine);
double centreEncreX = origine!.Value.X + (x0 + x1) / 2.0;
double centreEncreY = origine.Value.Y + (y0 + y1) / 2.0;
double centreBoutonX = (bouton.Bounds.Width - 1) / 2.0;
double centreBoutonY = (bouton.Bounds.Height - 1) / 2.0;
if (disposition == Disposition.IconeAvantLeLibelle)
{
// Rail latéral : le libellé suit l'icône sur la même ligne.
TextBlock etiquette = bouton.GetVisualDescendants().OfType<TextBlock>().Single();
Point? etiquetteOrigine = etiquette.TranslatePoint(new Point(0, 0), bouton);
Assert.True(
etiquetteOrigine is not null && etiquetteOrigine.Value.X >= origine.Value.X + cote,
$"Le libellé « {item.Title} » ({zone}) chevauche l'icône — {detail}.");
return;
}
// L'icône doit être centrée horizontalement dans son bouton, dans les deux
// dispositions de la barre basse. Tolérance élargie : le centre optique
// d'un tracé Material n'est pas toujours exactement le centre de la boîte
// (jusqu'à 1 px).
Assert.True(
Math.Abs(centreEncreX - centreBoutonX) <= Tolerance + 0.5,
$"Icône non centrée horizontalement dans son bouton — {detail} " +
$"(centres : icône={centreEncreX:0.#}, bouton={centreBoutonX:0.#}).");
if (disposition == Disposition.IconeAuDessusDuLibelle)
{
// Libellés affichés : c'est le groupe icône + libellé qui est centré
// verticalement dans le bouton (pas l'icône seule).
StackPanel pile = bouton.GetVisualDescendants().OfType<StackPanel>().First();
TextBlock etiquette = bouton.GetVisualDescendants().OfType<TextBlock>().Single();
Point? pileOrigine = pile.TranslatePoint(new Point(0, 0), bouton);
Assert.True(etiquette.IsVisible, $"Le libellé « {item.Title} » ({zone}) devrait être affiché.");
Assert.NotNull(pileOrigine);
Assert.True(
Math.Abs(pileOrigine!.Value.Y + (pile.Bounds.Height - 1) / 2.0 - centreBoutonY) <= Tolerance,
$"Le groupe icône + libellé « {item.Title} » ({zone}) n'est pas centré dans son bouton " +
$"(groupe={pile.Bounds.Height:0.#} px, bouton={bouton.Bounds.Height:0.#} px).");
return;
}
// Icône seule (libellés masqués sur écran étroit) : centrage dans les deux
// axes — c'est le critère visible par l'utilisateur.
Assert.True(
Math.Abs(centreEncreY - centreBoutonY) <= Tolerance + 0.5,
$"Icône non centrée verticalement dans son bouton — {detail} " +
$"(centres : icône={centreEncreY:0.#}, bouton={centreBoutonY:0.#}).");
}
/// <summary>Placement attendu de l'icône selon l'élément de navigation.</summary>
private enum Disposition
{
/// <summary>Barre basse, libellés masqués : l'icône est seule dans le bouton.</summary>
IconeSeuleDansLeBouton,
/// <summary>Barre basse, libellés affichés : icône au-dessus du libellé.</summary>
IconeAuDessusDuLibelle,
/// <summary>Rail latéral : icône puis libellé, sur la même ligne.</summary>
IconeAvantLeLibelle
}
private static Geometry Geometrie(string cle)
=> (Geometry)NavIconGeometryConverter.Instance
.Convert(cle, typeof(Geometry), null, CultureInfo.InvariantCulture)!;
/// <summary>Boîte englobante des pixels non transparents du rendu.</summary>
private static (int X0, int Y0, int X1, int Y1) EncreDe(SKBitmap bitmap)
{
int x0 = int.MaxValue;
int y0 = int.MaxValue;
int x1 = -1;
int y1 = -1;
for (int y = 0; y < bitmap.Height; y++)
{
for (int x = 0; x < bitmap.Width; x++)
{
if (bitmap.GetPixel(x, y).Alpha < 16)
{
continue;
}
x0 = Math.Min(x0, x);
y0 = Math.Min(y0, y);
x1 = Math.Max(x1, x);
y1 = Math.Max(y1, y);
}
}
return (x0, y0, x1, y1);
}
/// <summary>Page vide : la barre de navigation ne dépend d'aucun écran réel.</summary>
private sealed class PageVide : ViewModelBase;
/// <summary>Fabrique de pages neutre : aucune donnée métier n'est nécessaire.</summary>
private sealed class FakePageFactory : IPageViewModelFactory
{
public ViewModelBase Create(AppPage page) => new PageVide();
public ViewModelBase Create(AppPage page, object? parameter) => new PageVide();
}
private sealed class FakeSyncEngine : ISyncEngine
{
public Task<SyncCycleResult> PushAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
public Task<SyncCycleResult> SyncAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
}
private sealed class FakeOutboxService : IOutboxService
{
public Task<Guid> EnqueueAsync(
string entityType,
SyncOperationType operationType,
Guid requestedEntityGuid,
string payload,
string? dependencyEntityType = null,
Guid? dependencyRequestedEntityGuid = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(Guid.NewGuid());
public Task<int> CountPendingAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> CountNotYetSentAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
}
}
@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Constants;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Les blocs « Informations » (fiche Client) et « Accès et note » (fiche Site)
/// n'ont plus de titre (les champs sont explicites) et ne sont plus affichés du
/// tout quand aucune information n'est renseignée : plus de carte vide.
/// Ces tests inspectent l'arbre visuel RÉELLEMENT affiché.
/// </summary>
/// <remarks>
/// Session Avalonia headless : la classe rejoint la collection
/// « ApplicationRuntimeContext » pour qu'aucune autre session Avalonia ne
/// tourne en parallèle (le localisateur d'Avalonia est global au processus).
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class BlocsInformationsFicheTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
// ===== Fiche Client =====
[Fact]
public void FicheClient_BlocInformations_MasqueQuandVide_EtSansTitre()
{
Executer(() =>
{
var vueModele = new ClientDetailViewModel(null!, new NavigationService(), Permissions())
{
Client = new ClientDetail
{
ClientGuid = Guid.NewGuid(),
Nom = "REPETTO IMMOBILIER",
Code = "CLI-00123"
}
};
Window fenetre = MesureGeometrique.Ouvrir(new ClientDetailView { DataContext = vueModele }, 420);
IReadOnlyList<string> textes = TextesAffiches(fenetre);
// Aucune information renseignée : le bloc entier disparaît.
Assert.DoesNotContain("Informations", textes);
Assert.DoesNotContain("Forme juridique", textes);
Assert.DoesNotContain("SIREN", textes);
Assert.DoesNotContain("SIRET", textes);
Assert.DoesNotContain("Pays", textes);
Assert.DoesNotContain("Note", textes);
// Le reste de la fiche reste affiché.
Assert.Contains("REPETTO IMMOBILIER", textes);
Assert.Contains("← Retour", textes);
fenetre.Close();
});
}
[Fact]
public void FicheClient_BlocInformations_AfficheSansTitre_QuandRenseigne()
{
Executer(() =>
{
var vueModele = new ClientDetailViewModel(null!, new NavigationService(), Permissions())
{
Client = new ClientDetail
{
ClientGuid = Guid.NewGuid(),
Nom = "REPETTO IMMOBILIER",
FormeJuridique = "SARL",
InformationsComplementaires = "Client historique depuis 2011."
}
};
Window fenetre = MesureGeometrique.Ouvrir(new ClientDetailView { DataContext = vueModele }, 420);
IReadOnlyList<string> textes = TextesAffiches(fenetre);
// Les champs renseignés sont affichés, sans titre de bloc.
Assert.Contains("Forme juridique", textes);
Assert.Contains("SARL", textes);
Assert.Contains("Note", textes);
Assert.Contains("Client historique depuis 2011.", textes);
Assert.DoesNotContain("Informations", textes);
// Les champs non renseignés restent masqués (pas de libellé orphelin).
Assert.DoesNotContain("SIREN", textes);
Assert.DoesNotContain("SIRET", textes);
Assert.DoesNotContain("Pays", textes);
fenetre.Close();
});
}
// ===== Fiche Site =====
[Fact]
public void FicheSite_BlocAccesEtNote_MasqueQuandVide_EtSansTitre()
{
Executer(() =>
{
var vueModele = new SiteDetailViewModel(null!, new NavigationService(), Permissions(), null!)
{
Site = new SiteDetail
{
SiteGuid = Guid.NewGuid(),
Nom = "Siège social Marseille",
Code = "SIT-0001"
}
};
Window fenetre = MesureGeometrique.Ouvrir(new SiteDetailView { DataContext = vueModele }, 420);
IReadOnlyList<string> textes = TextesAffiches(fenetre);
Assert.DoesNotContain("Accès et note", textes);
Assert.DoesNotContain("Code d'accès", textes);
Assert.DoesNotContain("Note", textes);
// Le reste de la fiche reste affiché.
Assert.Contains("Siège social Marseille", textes);
Assert.Contains("← Retour", textes);
fenetre.Close();
});
}
[Fact]
public void FicheSite_BlocAccesEtNote_AfficheSansTitre_QuandRenseigne()
{
Executer(() =>
{
var vueModele = new SiteDetailViewModel(null!, new NavigationService(), Permissions(), null!)
{
Site = new SiteDetail
{
SiteGuid = Guid.NewGuid(),
Nom = "Siège social Marseille",
CodeAcces = "4512A",
InformationsComplementaires = "Sonner à l'interphone bâtiment B."
}
};
Window fenetre = MesureGeometrique.Ouvrir(new SiteDetailView { DataContext = vueModele }, 420);
IReadOnlyList<string> textes = TextesAffiches(fenetre);
Assert.Contains("Code d'accès", textes);
Assert.Contains("4512A", textes);
Assert.Contains("Note", textes);
Assert.Contains("Sonner à l'interphone bâtiment B.", textes);
Assert.DoesNotContain("Accès et note", textes);
fenetre.Close();
});
}
// ===== Calcul « renseigné » =====
[Fact]
public void Modele_UneInformationBlanche_CompteCommeAbsente()
{
var client = new ClientDetail
{
FormeJuridique = " ",
Siren = string.Empty,
Siret = null,
Pays = "\t",
InformationsComplementaires = " "
};
Assert.False(client.HasFormeJuridique);
Assert.False(client.HasSiren);
Assert.False(client.HasSiret);
Assert.False(client.HasPays);
Assert.False(client.HasInformationsComplementaires);
Assert.False(client.HasInformations);
var site = new SiteDetail { CodeAcces = " ", InformationsComplementaires = string.Empty };
Assert.False(site.HasCodeAcces);
Assert.False(site.HasInformationsComplementaires);
Assert.False(site.HasAccesOuNote);
Assert.True(new ClientDetail { Siren = "123456789" }.HasInformations);
Assert.True(new SiteDetail { CodeAcces = "4512A" }.HasAccesOuNote);
Assert.True(new SiteDetail { InformationsComplementaires = "Note seule" }.HasAccesOuNote);
}
// ===== Outils =====
private static PermissionService Permissions()
{
var permissions = new PermissionService();
permissions.SetRights(MobilePermissionKeys.All.ToDictionary(k => k, _ => true));
return permissions;
}
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// Textes réellement affichés : un contrôle dont un parent est masqué est
/// exclu (sinon un libellé d'un bloc masqué passerait pour visible).
/// </summary>
private static IReadOnlyList<string> TextesAffiches(Window fenetre)
{
Dispatcher.UIThread.RunJobs();
var textes = fenetre.GetVisualDescendants()
.OfType<TextBlock>()
.Where(t => t.IsEffectivelyVisible && !string.IsNullOrWhiteSpace(t.Text))
.Select(t => t.Text!)
.ToArray();
Assert.NotEmpty(textes);
return textes;
}
}
@@ -0,0 +1,224 @@
using System;
using System.Linq;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml.Templates;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Controls;
using Omika.App.ViewModels;
using Omika.Core.Contracts.Documents;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Champ TEXTE d'une rubrique dynamique (création de RAPPORT et composition
/// d'EMAIL) : la saisie doit être TOUJOURS multiligne.
///
/// <para>
/// Un champ sur une seule ligne masquait la fin d'une valeur longue : le texte
/// défilait horizontalement au lieu de revenir à la ligne, et l'utilisateur ne
/// voyait pas ce qu'il avait écrit. Le type RTF et la longueur de la valeur ne
/// décident donc plus du contrôle affiché.
/// </para>
///
/// <para>
/// Les deux écrans partagent le MÊME gabarit
/// (<c>avares://Omika.App/Views/ChampEditionTemplates.axaml</c>, clé
/// <c>ChampEditionDetailTemplate</c>) : ces tests portent donc sur le rendu réel
/// du gabarit commun, pas sur une copie.
/// </para>
/// </summary>
/// <remarks>
/// La classe rejoint la collection « ApplicationRuntimeContext » : le localisateur
/// d'Avalonia est global au processus, deux sessions headless ne doivent jamais
/// s'exécuter en parallèle. Les mesures sont lues À L'INTÉRIEUR du fil d'interface
/// (un champ n'a de géométrie qu'une fois disposé).
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class ChampTexteMultiligneTests : IDisposable
{
/// <summary>Gabarit partagé entre l'écran Rapport et l'écran de composition d'email.</summary>
private const string GabaritPartage = "avares://Omika.App/Views/ChampEditionTemplates.axaml";
private const string CleGabarit = "ChampEditionDetailTemplate";
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// Champ modifiable, valeur courte (« REF ») : le contrôle affiché est un champ
/// de saisie MULTILIGNE — retour à la ligne accepté et enroulement actif.
/// </summary>
[Fact]
public void ChampTexteModifiable_ValeurCourte_ResteMultiligne()
=> Executer(() =>
{
Window fenetre = Ouvrir(Texte(modifiable: true, valeur: "REF"));
try
{
DictationTextField dictee = ChampDeSaisie(fenetre);
TextBox saisie = dictee.FindControl<TextBox>("Saisie")!;
Assert.True(dictee.Multiline, "Le champ texte n'est pas en mode multiligne.");
// Deux marqueurs du multiligne : le retour à la ligne est accepté
// (touche Entrée) et le texte revient à la ligne tout seul.
Assert.True(saisie.AcceptsReturn, "Le champ n'accepte pas le retour à la ligne.");
Assert.Equal(TextWrapping.Wrap, saisie.TextWrapping);
// Et le champ est réellement plus haut qu'une ligne : la place des
// lignes suivantes existe dès l'ouverture.
Assert.True(
saisie.Bounds.Height >= 90,
$"Le champ ne fait que {saisie.Bounds.Height:0.##} px de haut.");
}
finally
{
fenetre.Close();
}
});
/// <summary>
/// Champ modifiable dont la valeur contient déjà un retour à la ligne : même
/// contrôle multiligne (aucun basculement de gabarit selon le contenu).
/// </summary>
[Fact]
public void ChampTexteModifiable_ValeurSurPlusieursLignes_ResteMultiligne()
=> Executer(() =>
{
Window fenetre = Ouvrir(Texte(modifiable: true, valeur: "ligne 1\nligne 2"));
try
{
TextBox saisie = ChampDeSaisie(fenetre).FindControl<TextBox>("Saisie")!;
Assert.True(saisie.AcceptsReturn);
Assert.Equal(TextWrapping.Wrap, saisie.TextWrapping);
Assert.Equal("ligne 1\nligne 2", saisie.Text);
}
finally
{
fenetre.Close();
}
});
/// <summary>
/// Un seul contrôle de saisie est construit pour un champ texte : la vue ne
/// peut pas basculer d'un champ simple vers un champ long selon la valeur.
/// </summary>
[Fact]
public void ChampTexte_UnSeulControleDeSaisie()
=> Executer(() =>
{
Window fenetre = Ouvrir(Texte(modifiable: true, valeur: "REF"));
try
{
DictationTextField[] champs = fenetre.GetVisualDescendants()
.OfType<DictationTextField>()
.ToArray();
// Un seul champ de saisie est DESSINÉ (les autres branches du
// gabarit restent masquées).
DictationTextField dessine = Assert.Single(champs, c => c.IsVisible);
Assert.True(dessine.Multiline);
}
finally
{
fenetre.Close();
}
});
/// <summary>
/// Champ NON modifiable : aucun contrôle de saisie, un texte qui revient à la
/// ligne (la lecture suit la même largeur d'écran que la saisie).
/// </summary>
[Fact]
public void ChampTexteNonModifiable_TexteQuiRevientALaLigne()
=> Executer(() =>
{
Window fenetre = Ouvrir(Texte(modifiable: false, valeur: "REF"));
try
{
Assert.DoesNotContain(
fenetre.GetVisualDescendants().OfType<DictationTextField>(),
c => c.IsVisible);
TextBlock texte = fenetre.GetVisualDescendants()
.OfType<TextBlock>()
.Single(t => t.IsVisible && t.Text == "REF");
Assert.Equal(TextWrapping.Wrap, texte.TextWrapping);
}
finally
{
fenetre.Close();
}
});
// =====================================================================
// Aides
// =====================================================================
/// <summary>Rubrique texte de test (type chaîne du modèle de rapport).</summary>
private static RapportChampEdition Texte(bool modifiable, string valeur)
=> new(new RapportChampDto
{
Id = "1",
Rubrique = "$REFINTERNE$",
GroupeApp = "INTER",
MembreMob = "IN_RefInterne",
Type = "ChpSimpleChaine",
ListeDonnesBD = valeur,
Modifiable = modifiable
});
/// <summary>
/// Construit le gabarit PARTAGÉ (Rapport + Email) pour cette rubrique et
/// l'affiche dans une fenêtre de largeur téléphone.
/// </summary>
private static Window Ouvrir(RapportChampEdition edition)
{
var dictionnaire = (ResourceDictionary)AvaloniaXamlLoader.Load(new Uri(GabaritPartage));
var gabarit = (DataTemplate)dictionnaire[CleGabarit];
Control contenu = gabarit.Build(edition)!;
// Le contexte est posé explicitement : sans lui, aucun binding du gabarit
// n'est évalué (les contrôleurs resteraient tous visibles, sans valeur),
// et le test ne prouverait rien.
contenu.DataContext = edition;
var fenetre = new Window
{
Width = 400,
Height = 900,
RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Light,
Content = contenu
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return fenetre;
}
/// <summary>Le champ de saisie dicté réellement construit par le gabarit.</summary>
private static DictationTextField ChampDeSaisie(Window fenetre)
=> fenetre.GetVisualDescendants().OfType<DictationTextField>().Single();
}
@@ -0,0 +1,87 @@
using Omika.App.Local.Models;
namespace Omika.Tests.Mobile;
/// <summary>
/// Tests des conventions de génération lues dans <c>config.json</c>.
///
/// <para>
/// Les valeurs reproduisent deux modèles réels : <c>opus-mt-es-fr</c>
/// (départ 74821, fin 0) et <c>opus-mt-tc-big-itc-itc</c> (départ 35282, fin
/// 29158). Aucune ne correspond aux identifiants du fichier <c>.spm</c>, que le
/// moteur utilisait jusqu'ici.
/// </para>
/// </summary>
public class ConfigurationModeleTests
{
[Fact]
public void DepuisJson_LitLesIdentifiantsDuModele()
{
const string json = """
{
"decoder_start_token_id": 35282,
"eos_token_id": 29158,
"pad_token_id": 35282,
"model_type": "marian"
}
""";
ConfigurationModele? configuration = ConfigurationModele.DepuisJson(json);
Assert.NotNull(configuration);
Assert.Equal(35282, configuration.IdDebutDecodeur);
Assert.Equal(29158, configuration.IdFin);
Assert.Equal(35282, configuration.IdRemplissage);
}
[Fact]
public void DepuisJson_FinAZero_EstUneValeurValide()
{
// 0 est un identifiant parfaitement légitime : sur opus-mt-es-fr, c'est
// </s>. Le confondre avec « absent » ferait générer jusqu'à la limite.
const string json = """{ "decoder_start_token_id": 74821, "eos_token_id": 0 }""";
ConfigurationModele? configuration = ConfigurationModele.DepuisJson(json);
Assert.NotNull(configuration);
Assert.Equal(0, configuration.IdFin);
}
[Fact]
public void DepuisJson_CleAbsente_ResteNulle()
{
ConfigurationModele? configuration = ConfigurationModele.DepuisJson("""{ "eos_token_id": 29158 }""");
Assert.NotNull(configuration);
Assert.Null(configuration.IdDebutDecodeur);
Assert.Equal(29158, configuration.IdFin);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("[]")]
[InlineData("null")]
public void DepuisJson_ContenuSansObjet_EstTraiteCommeAbsent(string? json)
{
Assert.Null(ConfigurationModele.DepuisJson(json));
}
[Fact]
public void DepuisJson_ValeurNonNumerique_EstIgnoree()
{
const string json = """{ "eos_token_id": "29158", "pad_token_id": -5 }""";
ConfigurationModele? configuration = ConfigurationModele.DepuisJson(json);
Assert.NotNull(configuration);
Assert.Null(configuration.IdFin);
Assert.Null(configuration.IdRemplissage);
}
[Fact]
public void DepuisJson_JsonInvalide_EstRefuse()
{
Assert.Throws<FormatException>(() => ConfigurationModele.DepuisJson("{ pas du json"));
}
}
@@ -0,0 +1,105 @@
using System;
using System.Linq;
using System.Threading;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Views;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Régression « bouton retour toujours accessible » : dans chaque page de détail,
/// le bouton « ← Retour » est construit dans un en-tête FIXE, donc HORS de la zone
/// de défilement — sinon il disparaît dès que l'utilisateur descend dans la page
/// et l'oblige à remonter pour revenir en arrière.
/// </summary>
/// <remarks>
/// Deux pièges de mesure sont évités ici :
/// <list type="bullet">
/// <item>la surcharge <c>Dispatch(Action)</c> n'attend pas la fin de l'action
/// (elle s'exécute après le retour de l'appel) : les assertions ne seraient
/// jamais évaluées dans le test — d'où l'appel à la surcharge générique, qui est
/// attendue ;</item>
/// <item>une vue non attachée à une fenêtre n'a pas d'arbre visuel : la vue est
/// donc réellement affichée dans une fenêtre avant d'être inspectée.</item>
/// </list>
/// La classe rejoint la collection « ApplicationRuntimeContext » : une session
/// Avalonia headless ne doit jamais s'exécuter en parallèle d'une autre, sous
/// peine de corrompre le localisateur global d'Avalonia.
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class DetailViewBackHeaderTests : IDisposable
{
private readonly HeadlessUnitTestSession _session;
public DetailViewBackHeaderTests()
=> _session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
/// <summary>Toutes les pages de détail exposant un bouton retour.</summary>
public static TheoryData<Type> VuesDeDetail => new()
{
typeof(ClientDetailView),
typeof(DossierDetailView),
typeof(SiteDetailView),
typeof(ContactDetailView),
typeof(CollaborateurDetailView),
typeof(EquipeDetailView),
typeof(InterventionDetailView),
typeof(InterventionContactsView),
typeof(InterventionEmailsView),
typeof(InterventionNotesView),
typeof(InterventionNotesAdministrativesView),
typeof(InterventionResourcesView),
typeof(InterventionDocumentsView),
typeof(CompteRenduCreateView),
typeof(RendezVousDetailView),
typeof(RapportChampsView),
typeof(NouveauEmailView),
typeof(SynchronisationView),
typeof(AgendaEquipeView)
};
[Theory]
[MemberData(nameof(VuesDeDetail))]
public void BoutonRetour_EstHorsZoneDeDefilement(Type typeVue)
{
_session.Dispatch(
() =>
{
var vue = (UserControl)Activator.CreateInstance(typeVue)!;
// Vue réellement affichée : sans cela l'arbre visuel est vide et
// le test passerait sans rien vérifier.
var fenetre = new Window { Width = 420, Height = 800, Content = vue };
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Control[] descendants = vue.GetVisualDescendants().OfType<Control>().ToArray();
Button[] boutonsRetour = descendants
.OfType<Button>()
.Where(b => (b.Content as string)?.Contains("Retour", StringComparison.Ordinal) == true)
.ToArray();
Assert.NotEmpty(boutonsRetour);
// Au moins un bouton retour est FIXE : aucun ScrollViewer au-dessus
// de lui, il ne défile donc pas avec le contenu.
Assert.Contains(
boutonsRetour,
b => !b.GetVisualAncestors().Any(a => a is ScrollViewer));
// Le contenu de la page, lui, reste défilable.
Assert.Contains(descendants, c => c is ScrollViewer);
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
}
@@ -0,0 +1,965 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Headless;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Omika.App.Controls;
using Omika.App.Local.Abstractions;
using Omika.App.Services;
using SkiaSharp;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Champ de saisie avec dictée vocale : le micro n'apparaît que si un moteur de
/// dictée existe (jamais sur Desktop), l'écoute écrit dans le champ lié, le texte
/// dicté s'ajoute à la fin, et aucun échec ne fait perdre la saisie.
/// </summary>
/// <remarks>
/// Session Avalonia headless : la classe rejoint la collection
/// « ApplicationRuntimeContext » pour qu'aucune autre session Avalonia ne tourne
/// en parallèle (le localisateur d'Avalonia est global au processus).
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class DictationTextFieldTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose()
{
// Le point d'injection est statique : on le remet à zéro entre les classes.
PlatformDictation.Current = null;
PlatformDicteeTraduite.Current = null;
PlatformDicteeTraduite.Invalider();
_session.Dispose();
}
// ===== Visibilité du micro =====
[Fact]
public void Micro_MasqueSansMoteurDeDictee_LeChampResteUnChampNormal()
{
Executer(() =>
{
PlatformDictation.Current = null; // Desktop/Windows : aucune dictée
var champ = new DictationTextField { PlaceholderText = "Ville" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.False(Micro(champ).IsVisible);
Assert.Equal("Ville", Saisie(champ).PlaceholderText);
Assert.Contains("field", Saisie(champ).Classes);
// La hauteur minimale du style « field » n'est pas écrasée par le contrôle.
Assert.True(Saisie(champ).MinHeight > 0);
fenetre.Close();
});
}
[Fact]
public void Micro_AfficheQuandLeMoteurExiste()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.True(Micro(champ).IsVisible);
fenetre.Close();
});
}
[Fact]
public void Micro_MasqueQuandLeChampEstEnLectureSeule()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField { IsReadOnly = true };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.False(Micro(champ).IsVisible);
Assert.True(Saisie(champ).IsReadOnly);
fenetre.Close();
});
}
// ===== Dictée =====
[Fact]
public void ClicMicro_AjouteLeTexteDicteALaFinDuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux réalisés." };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// L'écoute est en cours : le bouton passe en mode « arrêter ».
Assert.Contains("ecoute", Micro(champ).Classes);
service.Session.Partiel("Remplacement de la pièce");
Assert.Equal("Travaux réalisés. Remplacement de la pièce", champ.Text);
service.Session.Final("Remplacement de la pièce.");
Assert.Equal("Travaux réalisés. Remplacement de la pièce.", champ.Text);
Assert.DoesNotContain("ecoute", Micro(champ).Classes);
Assert.True(service.Session.Liberee);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_LeDernierTextePartielRemplaceLePrecedent_SansDoublon()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Note :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("Bonjour");
service.Session.Partiel("Bonjour le monde");
// Chaque partiel repart du texte présent AVANT la dictée.
Assert.Equal("Note : Bonjour le monde", champ.Text);
service.Session.Final("Bonjour le monde.");
Assert.Equal("Note : Bonjour le monde.", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_EcritDansLaProprieteLieeDuModele()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var modele = new ModeleTexte { Texte = "Départ" };
var champ = new DictationTextField();
champ.Bind(
DictationTextField.TextProperty,
new Binding(nameof(ModeleTexte.Texte)) { Source = modele, Mode = BindingMode.TwoWay });
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.Equal("Départ", champ.Text);
Cliquer(Micro(champ));
service.Session.Final("Remplacement du filtre.");
// Le texte dicté arrive bien dans la propriété du ViewModel.
Assert.Equal("Départ Remplacement du filtre.", modele.Texte);
fenetre.Close();
});
}
[Fact]
public void DeuxiemeClic_ArreteLaDicteeEtConserveLeDernierTexteReconnu()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
service.Session.TexteSurArret = "pièce commandée."; // comportement Android
var champ = new DictationTextField { Text = "Note :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("pièce commandée");
Cliquer(Micro(champ)); // arrêt demandé par l'utilisateur
Assert.True(service.Session.ArretDemande);
Assert.Equal("Note : pièce commandée.", champ.Text);
Assert.DoesNotContain("ecoute", Micro(champ).Classes);
fenetre.Close();
});
}
// ===== Échecs : la saisie n'est jamais perdue =====
[Fact]
public void ClicMicro_MicroRefuse_AfficheLeMessageSansToucherAuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee { Demarre = false }; // autorisation refusée
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Assert.True(Message(champ).IsVisible);
Assert.Equal(DictationMessages.MicroRefuse, Message(champ).Text);
Assert.Contains("erreur", Message(champ).Classes);
Assert.Equal("Travaux", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_EchecEnCoursDEcoute_ConserveLeTexteDejaReconnu()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Constat :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("fuite sous l'évier");
service.Session.Echec(DictationMessages.Interrompue);
Assert.Equal(DictationMessages.Interrompue, Message(champ).Text);
Assert.Equal("Constat : fuite sous l'évier", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_Silence_AfficheRienEntenduSansToucherAuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Fini(); // rien entendu
Assert.True(Message(champ).IsVisible);
Assert.Equal(DictationMessages.RienEntendu, Message(champ).Text);
Assert.Contains("info", Message(champ).Classes);
Assert.Equal("Travaux", champ.Text);
fenetre.Close();
});
}
// ===== Sortie d'écran =====
[Fact]
public void QuitterLEcran_ArreteLaDicteeEtLibereLeMoteur()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Assert.False(service.Session.Liberee);
// La page quitte l'arbre visuel (navigation) : le micro doit se fermer.
fenetre.Content = new TextBlock { Text = "Autre écran" };
Dispatcher.UIThread.RunJobs();
Assert.True(service.Session.ArretDemande);
Assert.True(service.Session.Liberee);
fenetre.Close();
});
}
// ===== Présentation du champ =====
[Fact]
public void ChampMultiligne_RetoursALaLigneEtHauteurMinimale()
{
Executer(() =>
{
PlatformDictation.Current = null;
var champ = new DictationTextField { Multiline = true, InputMinHeight = 90 };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
TextBox saisie = Saisie(champ);
Assert.True(saisie.AcceptsReturn);
Assert.Equal(TextWrapping.Wrap, saisie.TextWrapping);
Assert.Equal(90, saisie.MinHeight);
fenetre.Close();
});
}
[Fact]
public void TexteIndicatif_WatermarkEtPlaceholderText_SontReportesSurLeChamp()
{
Executer(() =>
{
PlatformDictation.Current = null;
var avecWatermark = new DictationTextField { Watermark = "Valeur" };
var avecPlaceholder = new DictationTextField { PlaceholderText = "Ville" };
var pile = new StackPanel { Children = { avecWatermark, avecPlaceholder } };
Window fenetre = MesureGeometrique.Ouvrir(pile, 400);
Assert.Equal("Valeur", Saisie(avecWatermark).PlaceholderText);
Assert.Equal("Ville", Saisie(avecPlaceholder).PlaceholderText);
fenetre.Close();
});
}
[Fact]
public void ClasseDeSaisie_ParDefautField_EtRetirablePourLesChampsSansClasse()
{
Executer(() =>
{
PlatformDictation.Current = null;
var parDefaut = new DictationTextField();
var sansClasse = new DictationTextField { InputClass = string.Empty };
var pile = new StackPanel { Children = { parDefaut, sansClasse } };
Window fenetre = MesureGeometrique.Ouvrir(pile, 400);
Assert.Contains("field", Saisie(parDefaut).Classes);
// Classes contient aussi les pseudo-classes (":empty", ":focus"…) :
// on vérifie l'absence de la classe de style, pas la vacuité.
Assert.DoesNotContain("field", Saisie(sansClasse).Classes);
fenetre.Close();
});
}
// ===== Centrage de l'icône =====
/// <summary>
/// Régression mesurée : l'icône du micro était décalée de 2,5 px vers la
/// GAUCHE dans son bouton. La géométrie Material mesure 14×19 ; mise à
/// l'échelle par <c>Stretch="Uniform"</c> dans une boîte de 20×20, la
/// transformation part de l'origine du contrôle et laisse les 2,6 px de jeu à
/// droite. L'icône est désormais dessinée en taille native dans une boîte
/// carrée 24×24 : le centrage est vérifié sur le rendu Skia RÉEL, dans les
/// deux états du bouton (aucune hypothèse sur la géométrie ni sur Stretch).
/// </summary>
[Fact]
public void Micro_IconeCentreDansLeBouton_DansLesDeuxEtats()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField { PlaceholderText = "Test" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Button micro = Micro(champ);
VerifierIconeCentree(micro, "dicter", largeurEncre: 14, hauteurEncre: 19);
Cliquer(micro);
Dispatcher.UIThread.RunJobs();
VerifierIconeCentree(micro, "écoute", largeurEncre: 16, hauteurEncre: 16);
fenetre.Close();
});
}
/// <summary>
/// Mesure le rendu réel du bouton : le centre de l'encre doit coïncider avec
/// le centre du bouton (tolérance 1 px : l'encre est comptée en pixels
/// entiers), et l'icône rendue doit être celle de l'état attendu (le micro et
/// le carré d'arrêt n'ont pas la même empreinte).
/// </summary>
private static void VerifierIconeCentree(Button bouton, string etat, int largeurEncre, int hauteurEncre)
{
int largeur = (int)Math.Round(bouton.Bounds.Width);
int hauteur = (int)Math.Round(bouton.Bounds.Height);
Assert.True(largeur > 0 && hauteur > 0, "Le bouton micro n'a pas été disposé.");
using var cible = new RenderTargetBitmap(new PixelSize(largeur, hauteur), new Vector(96, 96));
cible.Render(bouton);
using var flux = new MemoryStream();
cible.Save(flux);
using var bitmap = SKBitmap.Decode(flux.ToArray());
(int x0, int y0, int x1, int y1) = EncreDe(bitmap);
Assert.True(x1 >= x0, $"Aucune encre rendue dans le bouton micro (état « {etat} »).");
double centreEncreX = (x0 + x1) / 2.0;
double centreEncreY = (y0 + y1) / 2.0;
double centreBoutonX = (largeur - 1) / 2.0;
double centreBoutonY = (hauteur - 1) / 2.0;
string detail =
$"état « {etat} » : encre=[{x0},{y0} .. {x1},{y1}] " +
$"centre=({centreEncreX};{centreEncreY}) bouton={largeur}x{hauteur} " +
$"centre=({centreBoutonX};{centreBoutonY})";
Assert.True(
Math.Abs(centreEncreX - centreBoutonX) <= 1.0 && Math.Abs(centreEncreY - centreBoutonY) <= 1.0,
$"Icône non centrée dans le bouton — {detail}");
Assert.True(
Math.Abs((x1 - x0 + 1) - largeurEncre) <= 1 && Math.Abs((y1 - y0 + 1) - hauteurEncre) <= 1,
$"Icône inattendue (empreinte {x1 - x0 + 1}x{y1 - y0 + 1}, " +
$"attendu {largeurEncre}x{hauteurEncre}) — {detail}");
}
/// <summary>Boîte englobante des pixels non transparents du rendu.</summary>
private static (int X0, int Y0, int X1, int Y1) EncreDe(SKBitmap bitmap)
{
int x0 = int.MaxValue;
int y0 = int.MaxValue;
int x1 = -1;
int y1 = -1;
for (int y = 0; y < bitmap.Height; y++)
{
for (int x = 0; x < bitmap.Width; x++)
{
if (bitmap.GetPixel(x, y).Alpha < 16)
{
continue;
}
x0 = Math.Min(x0, x);
y0 = Math.Min(y0, y);
x1 = Math.Max(x1, x);
y1 = Math.Max(y1, y);
}
}
return (x0, y0, x1, y1);
}
// ===== Outils =====
private static Button Micro(DictationTextField champ) => champ.FindControl<Button>("Micro")!;
private static TextBox Saisie(DictationTextField champ) => champ.FindControl<TextBox>("Saisie")!;
// ===== Dictée traduite (langue secondaire) =====
[Fact]
public void Dictee_SansLangueSecondaire_TransmetLeFrancaisAuMoteur()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
PlatformDicteeTraduite.Current = null;
PlatformDicteeTraduite.Invalider();
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// Le français est la langue de référence : jamais une langue codée en
// dur ni une culture vide (un moteur natif refuse une langue vide).
Assert.Equal("fr", service.DerniereLangue);
// Aucune langue secondaire : pas de panneau, comportement historique.
Assert.False(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_BasculeEnLangueSecondaire_RedemarreEtNInsereQueLeFrancais()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Traduction = "Remplacement de la pièce"
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// Le panneau de dictée s'ouvre et propose le choix de langue.
Assert.True(PanneauOuvert(champ));
Assert.True(PuceSecondaire(champ).IsVisible);
Assert.Equal("fr", service.DerniereLangue);
// Choix de l'espagnol : l'écoute REDÉMARRE dans la nouvelle langue
// (un moteur natif ne change pas de langue en cours de session).
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
Assert.Equal("es", service.DerniereLangue);
Assert.Equal(2, service.NombreDemarrages);
// Le panneau reste ouvert : c'est lui qui porte le choix de langue.
Assert.True(PanneauOuvert(champ));
// Le texte dicté en espagnol n'entre PAS tel quel et n'est PAS
// traduit d'office : la dictée s'arrête en relecture, pour que
// l'utilisateur puisse corriger ce que le moteur a entendu.
service.Session.Final("Sustitución de la pieza");
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.True(PanneauOuvert(champ));
Assert.True(Valider(champ).IsVisible);
Assert.False(Arreter(champ).IsVisible);
Assert.Equal("Sustitución de la pieza", ZoneReconnu(champ).Text);
// Validation : c'est seulement maintenant que le français traduit
// entre dans le champ, et le panneau se referme.
Cliquer(Valider(champ));
Attendre(() => string.Equals(champ.Text, "Remplacement de la pièce", StringComparison.Ordinal));
Assert.Equal("Remplacement de la pièce", champ.Text);
Assert.False(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_TexteCorrige_EstCeluiQuiPartEnTraduction()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var serviceTraduction = new FauxServiceDicteeTraduite
{
Traduction = "Salle 3 du bâtiment B"
};
InstallerDicteeTraduite(serviceTraduction);
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
// Pendant l'écoute, le texte reconnu est en LECTURE seule : chaque
// mot reconnu remplace le précédent.
Assert.True(ZoneReconnu(champ).IsReadOnly);
// La zone éditable ne double pas le cadre : c'est le Border
// « ZoneReconnu » qui dessine le contour, le champ reste transparent.
Assert.Equal(0, ZoneReconnu(champ).BorderThickness.Left);
var fondZone = Assert.IsAssignableFrom<ISolidColorBrush>(ZoneReconnu(champ).Background);
Assert.Equal((byte)0, fondZone.Color.A);
// Le moteur a mal entendu : « schema » au lieu de « sala ».
service.Session.Final("schema tres del edificio B");
// Écoute finie : la zone de texte reconnu devient MODIFIABLE.
Assert.False(ZoneReconnu(champ).IsReadOnly);
ZoneReconnu(champ).Text = "sala tres del edificio B";
Cliquer(Valider(champ));
Attendre(() => string.Equals(champ.Text, "Salle 3 du bâtiment B", StringComparison.Ordinal));
// La correction de l'utilisateur — pas la reconnaissance brute — est
// ce qui a été traduit.
Assert.Equal("sala tres del edificio B", serviceTraduction.DernierTexteRecu);
Assert.Equal("Salle 3 du bâtiment B", champ.Text);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_AnnulationEnRelecture_NInsereRien()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Traduction = "Remplacement de la pièce"
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
service.Session.Final("Sustitución de la pieza");
Cliquer(Annuler(champ));
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.False(PanneauOuvert(champ));
Assert.Null(champ.FindControl<TextBox>("TexteReconnu")!.Text);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_RetourAuFrancais_RedemarreSansFermerLePanneau()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite());
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
Assert.Equal("es", service.DerniereLangue);
// Retour au français : nouvelle écoute, panneau toujours ouvert.
Cliquer(PuceFrancais(champ));
AttendreRelance(service, 3);
Assert.Equal("fr", service.DerniereLangue);
Assert.True(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_RelanceImpossible_GardeLePanneauOuvertEtExplique()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite());
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// La nouvelle écoute ne peut pas s'ouvrir (moteur indisponible) :
// la popup ne doit PAS disparaître, sinon l'utilisateur ne peut plus
// revenir au français ni annuler.
service.Demarre = false;
Cliquer(PuceSecondaire(champ));
Attendre(() => string.Equals(
MessagePanneau(champ).Text,
DictationMessages.LangueIndisponible,
StringComparison.Ordinal));
Assert.True(PanneauOuvert(champ));
Assert.True(MessagePanneau(champ).IsVisible);
Assert.Equal(DictationMessages.LangueIndisponible, MessagePanneau(champ).Text);
// Trois ouvertures tentées après la première écoute : le moteur est
// peut-être simplement en train de rendre le micro.
Assert.Equal(1 + 3, service.NombreDemarrages);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_TraductionImpossible_ConserveLeTexteDicte()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Echec = new TranslationException("Modèle illisible.")
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
service.Session.Final("Sustitución de la pieza");
// Rien n'est traduit avant validation : la panne ne se manifeste
// qu'une fois le texte relu et validé.
Cliquer(Valider(champ));
Attendre(() => InsererSecours(champ).IsVisible);
// Une panne de traduction ne doit JAMAIS faire perdre la dictée :
// rien n'est inséré, mais le panneau propose l'insertion de secours.
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.True(PanneauOuvert(champ));
Assert.True(InsererSecours(champ).IsVisible);
fenetre.Close();
});
}
/// <summary>
/// Installe un service de dictée traduite dont la langue secondaire est
/// exploitable (espagnol, moteur présent, modèles installés).
/// </summary>
private static void InstallerDicteeTraduite(FauxServiceDicteeTraduite service)
{
service.Etat = new DicteeTraduiteEtat
{
LangueSecondaireCode = "es",
LangueSecondaireLibelle = "Espagnol",
TraductionDisponible = true,
ModelesInstalles = true
};
PlatformDicteeTraduite.Current = service;
// Le cache d'état est statique : sans invalidation, un test précédent
// imposerait son état au suivant.
PlatformDicteeTraduite.Invalider();
}
private static TextBlock Message(DictationTextField champ) => champ.FindControl<TextBlock>("Message")!;
/// <summary>
/// Vrai si le panneau de dictée est OUVERT. On teste <c>Popup.IsOpen</c> et non
/// <c>IsVisible</c> du panneau : ce dernier reste à sa valeur locale même
/// lorsque la bulle est fermée, donc il ne prouverait rien.
/// </summary>
private static bool PanneauOuvert(DictationTextField champ)
=> champ.FindControl<Popup>("PopupDictee")!.IsOpen;
/// <summary>Puce « langue secondaire » du panneau de dictée.</summary>
private static Button PuceSecondaire(DictationTextField champ)
=> champ.FindControl<Button>("PuceSecondaire")!;
/// <summary>Puce « français » du panneau de dictée.</summary>
private static Button PuceFrancais(DictationTextField champ)
=> champ.FindControl<Button>("PuceFrancais")!;
/// <summary>Message d'état affiché DANS le panneau de dictée.</summary>
private static TextBlock MessagePanneau(DictationTextField champ)
=> champ.FindControl<TextBlock>("MessageDictee")!;
/// <summary>
/// Attend qu'une condition soit vraie en pompant la file d'interface. Un
/// changement de langue impose une pause réelle (le système doit d'abord
/// rendre le micro), donc les assertions ne peuvent pas suivre le clic
/// immédiatement.
/// </summary>
private static void Attendre(Func<bool> condition, int secondesMaximum = 15)
{
DateTime limite = DateTime.UtcNow.AddSeconds(secondesMaximum);
while (!condition() && DateTime.UtcNow < limite)
{
Dispatcher.UIThread.RunJobs();
Thread.Sleep(10);
}
Dispatcher.UIThread.RunJobs();
}
/// <summary>Attend que la relance ait ouvert l'écoute attendue.</summary>
private static void AttendreRelance(FauxServiceDictee service, int demarragesAttendus)
=> Attendre(() => service.NombreDemarrages >= demarragesAttendus);
/// <summary>Bouton d'insertion de secours (traduction impossible).</summary>
private static Button InsererSecours(DictationTextField champ)
=> champ.FindControl<Button>("BoutonInserer")!;
/// <summary>Bouton de validation du texte dicté (écoute terminée).</summary>
private static Button Valider(DictationTextField champ)
=> champ.FindControl<Button>("BoutonValider")!;
/// <summary>Bouton d'arrêt de l'écoute.</summary>
private static Button Arreter(DictationTextField champ)
=> champ.FindControl<Button>("BoutonArreter")!;
/// <summary>Bouton d'abandon de la dictée.</summary>
private static Button Annuler(DictationTextField champ)
=> champ.FindControl<Button>("BoutonAnnuler")!;
/// <summary>Zone du texte reconnu : lecture seule pendant l'écoute, modifiable ensuite.</summary>
private static TextBox ZoneReconnu(DictationTextField champ)
=> champ.FindControl<TextBox>("TexteReconnu")!;
/// <summary>Un clic réel sur le bouton micro (thread d'interface headless).</summary>
private static void Cliquer(Button bouton) => bouton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
ReinitialiserMemoireLangue();
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// La dernière langue de dictée est mémorisée dans un champ STATIQUE (confort
/// de saisie, jamais persisté). Sans remise à zéro, un test hériterait du
/// choix du précédent et l'ordre d'exécution changerait le résultat.
/// </summary>
private static void ReinitialiserMemoireLangue()
=> typeof(DictationTextField)
.GetField("derniereLangueDictee", BindingFlags.NonPublic | BindingFlags.Static)
?.SetValue(null, null);
/// <summary>Faux service de dictée : aucune plateforme n'est nécessaire.</summary>
private sealed class FauxServiceDictee : IDictationService
{
public bool IsAvailable { get; set; } = true;
/// <summary>Faux pour simuler une autorisation micro refusée.</summary>
public bool Demarre { get; set; } = true;
public FauxSessionDictee Session { get; } = new();
/// <summary>
/// Dernière langue demandée au moteur : permet de vérifier que le champ
/// transmet bien la langue secondaire choisie (et le français par
/// défaut) au lieu d'une langue codée en dur.
/// </summary>
public string? DerniereLangue { get; private set; }
/// <summary>Nombre d'écoutes démarrées (une bascule en démarre une seconde).</summary>
public int NombreDemarrages { get; private set; }
public Task<IDictationSession?> StartAsync(
string? codeLangue = null,
CancellationToken cancellationToken = default)
{
DerniereLangue = codeLangue;
NombreDemarrages++;
return Task.FromResult<IDictationSession?>(Demarre ? Session : null);
}
}
/// <summary>
/// Faux service de dictée traduite : aucune préférence SQLite ni modèle ONNX
/// n'est nécessaire pour vérifier le comportement du champ de saisie.
/// </summary>
private sealed class FauxServiceDicteeTraduite : IDicteeTraduiteService
{
/// <summary>État renvoyé par <see cref="GetEtatAsync"/>.</summary>
public DicteeTraduiteEtat Etat { get; set; } = new();
/// <summary>Texte français renvoyé par la traduction.</summary>
public string Traduction { get; set; } = string.Empty;
/// <summary>Erreur à lever à la place d'une traduction (cas de panne).</summary>
public Exception? Echec { get; set; }
/// <summary>
/// Dernier texte réellement transmis à la traduction : permet de vérifier
/// que c'est bien le texte RELU (et corrigé) par l'utilisateur qui part,
/// et non la reconnaissance brute du moteur.
/// </summary>
public string? DernierTexteRecu { get; private set; }
public Task<DicteeTraduiteEtat> GetEtatAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Etat);
public Task<string> TraduireVersFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
{
DernierTexteRecu = texte;
return Echec is null ? Task.FromResult(Traduction) : Task.FromException<string>(Echec);
}
public Task<string> TraduireDepuisFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
{
DernierTexteRecu = texte;
return Echec is null ? Task.FromResult(Traduction) : Task.FromException<string>(Echec);
}
}
private sealed class FauxSessionDictee : IDictationSession
{ public event EventHandler<string>? PartialText;
public event EventHandler<string>? FinalText;
public event EventHandler<string>? Failed;
public event EventHandler? Ended;
/// <summary>Texte livré par le moteur au moment de l'arrêt (cas Android).</summary>
public string? TexteSurArret { get; set; }
public bool ArretDemande { get; private set; }
public bool Liberee { get; private set; }
public void Partiel(string texte) => PartialText?.Invoke(this, texte);
public void Final(string texte) => FinalText?.Invoke(this, texte);
public void Echec(string message) => Failed?.Invoke(this, message);
public void Fini() => Ended?.Invoke(this, EventArgs.Empty);
public Task StopAsync()
{
ArretDemande = true;
if (TexteSurArret is { } texte)
{
Final(texte);
}
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
Liberee = true;
return ValueTask.CompletedTask;
}
}
/// <summary>Modèle minimal pour vérifier le lien deux-sens avec le ViewModel.</summary>
private sealed class ModeleTexte : INotifyPropertyChanged
{
private string? _texte;
public string? Texte
{
get => _texte;
set
{
_texte = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Texte)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -0,0 +1,103 @@
using Omika.Data.Repositories;
namespace Omika.Tests.Mobile;
/// <summary>
/// Tests de la génération de nom unique des documents côté serveur
/// (DocumentPathResolver.WriteUniqueFile) : jamais d'écrasement, nom en base ==
/// nom physique, écriture atomique anti-collision concurrente, anti path traversal.
/// </summary>
public class DocumentUniqueFileNameTests : IDisposable
{
private readonly string _dir;
public DocumentUniqueFileNameTests()
{
_dir = Path.Combine(Path.GetTempPath(), "omika-unique", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
[Fact]
public void PremierUpload_AucunExistant_ConserveLeNom()
{
// Act
string? name = DocumentPathResolver.WriteUniqueFile(_dir, "Viamichelin.pdf", [1, 2, 3]);
// Assert
Assert.Equal("Viamichelin.pdf", name);
Assert.True(File.Exists(Path.Combine(_dir, "Viamichelin.pdf")));
}
[Fact]
public void DeuxiemeUpload_MemeNom_GenereSuffixe_EtConserveLePremier()
{
// Arrange : Viamichelin.pdf existe déjà.
File.WriteAllBytes(Path.Combine(_dir, "Viamichelin.pdf"), new byte[] { 1, 1, 1 });
// Act
string? name = DocumentPathResolver.WriteUniqueFile(_dir, "Viamichelin.pdf", new byte[] { 2, 2, 2 });
// Assert : le second devient Viamichelin_1.pdf, le premier est intact.
Assert.Equal("Viamichelin_1.pdf", name);
Assert.Equal(new byte[] { 1, 1, 1 }, File.ReadAllBytes(Path.Combine(_dir, "Viamichelin.pdf")));
Assert.Equal(new byte[] { 2, 2, 2 }, File.ReadAllBytes(Path.Combine(_dir, "Viamichelin_1.pdf")));
}
[Fact]
public void TroisiemeUpload_DeuxExistants_GenereLeSuffixeSuivant()
{
// Arrange : Viamichelin.pdf et Viamichelin_1.pdf existent déjà.
File.WriteAllBytes(Path.Combine(_dir, "Viamichelin.pdf"), new byte[] { 1 });
File.WriteAllBytes(Path.Combine(_dir, "Viamichelin_1.pdf"), new byte[] { 2 });
// Act
string? name = DocumentPathResolver.WriteUniqueFile(_dir, "Viamichelin.pdf", new byte[] { 3 });
// Assert
Assert.Equal("Viamichelin_2.pdf", name);
Assert.True(File.Exists(Path.Combine(_dir, "Viamichelin_2.pdf")));
}
[Fact]
public void UploadsConcurrents_MemeNom_ProduisentDesNomsDistincts()
{
// Act : écritures parallèles du même nom → chacune doit obtenir un nom unique.
var results = new string?[8];
Parallel.For(0, results.Length, i =>
{
results[i] = DocumentPathResolver.WriteUniqueFile(_dir, "Viamichelin.pdf", [(byte)i]);
});
// Assert : 8 noms distincts (aucun écrasement).
Assert.Equal(results.Length, results.Distinct().Count());
Assert.Equal(results.Length, Directory.GetFiles(_dir).Length);
}
[Fact]
public void NomDangereux_PathTraversal_Bloque()
{
// Act : "..\..\Viamichelin.pdf" doit être réduit au simple nom de fichier.
string? name = DocumentPathResolver.WriteUniqueFile(_dir, @"..\..\Viamichelin.pdf", [1, 2, 3]);
// Assert : aucun fichier hors du répertoire, le nom est assaini.
Assert.Equal("Viamichelin.pdf", name);
Assert.True(File.Exists(Path.Combine(_dir, "Viamichelin.pdf")));
Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(_dir)!, "Viamichelin.pdf")));
}
[Fact]
public void NomAvecCaracteresInvalides_EstNettoye()
{
// Act + Assert
Assert.Equal("Viami_chel_in.pdf", DocumentPathResolver.SanitizeFileName("Viami<chel>in.pdf"));
Assert.Equal("document", DocumentPathResolver.SanitizeFileName(" "));
}
}

Some files were not shown because too many files have changed in this diff Show More