Premiere commit - Omika
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user