Premiere commit - Omika
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,791 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Organisation;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="AdaptiveCollaborateurRepository"/> : délégation,
|
||||
/// comportement Unknown, propagation d'erreurs, architecture, et DI.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class AdaptiveCollaborateurRepositoryTests : IDisposable
|
||||
{
|
||||
public AdaptiveCollaborateurRepositoryTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION SEARCH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikDetected_DelegatesToBatikRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [BatikCollaborateurDict(1, "Dupont", "Jean")];
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new CollaborateurSearchCriteria { Search = "Dupont" });
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OmikaDetected_DelegatesToOmikaRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.CollaborateurRows = [OmikaCollaborateurDict(1, "Dupont", "Jean")];
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new CollaborateurSearchCriteria { Search = "Dupont" });
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new CollaborateurSearchCriteria()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_EmptyCriteria_DoesNotCrash()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<Collaborateur> result = await adaptive.SearchAsync(new CollaborateurSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION DETAIL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [BatikCollaborateurDict(42, "Dupont", "Jean")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
Collaborateur? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.CollaborateurRows = [OmikaCollaborateurDict(42, "Dupont", "Jean")];
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
Collaborateur? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_NullFromRepository_ReturnsNull()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
Collaborateur? result = await adaptive.GetDetailsAsync("999");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROPAGATION D'ERREURS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new CollaborateurSearchCriteria()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DI TESTS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersBatikCollaborateurRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(BatikCollaborateurRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersOmikaCollaborateurRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(OmikaCollaborateurRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersICollaborateurRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ICollaborateurRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_ICollaborateurRepositoryPointsToAdaptiveCollaborateurRepository()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ICollaborateurRepository));
|
||||
Assert.Equal(typeof(AdaptiveCollaborateurRepository), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_OnlyOneICollaborateurRepositoryRegistered()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(ICollaborateurRepository)).ToList();
|
||||
Assert.Single(descriptors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOmikaData_ResolvesAllCollaborateurRepositories_WithFakeConnection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddOmikaData();
|
||||
|
||||
var oldDescriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
|
||||
services.Remove(oldDescriptor);
|
||||
services.AddScoped<IHfsqlConnection, MinimalFakeHfsqlConnection>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var collaborateurRepo = scope.ServiceProvider.GetRequiredService<ICollaborateurRepository>();
|
||||
Assert.IsType<AdaptiveCollaborateurRepository>(collaborateurRepo);
|
||||
|
||||
var batikRepo = scope.ServiceProvider.GetRequiredService<BatikCollaborateurRepository>();
|
||||
Assert.NotNull(batikRepo);
|
||||
|
||||
var omikaRepo = scope.ServiceProvider.GetRequiredService<OmikaCollaborateurRepository>();
|
||||
Assert.NotNull(omikaRepo);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION UPDATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [BatikCollaborateurDict(42, "Dupont", "Jean")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync("42", new CollaborateurUpdateCommand { Nom = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.CollaborateurRows = [OmikaCollaborateurDict(42, "Dupont", "Jean")];
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync(Guid.NewGuid().ToString("D"), new CollaborateurUpdateCommand { Nom = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.UpdateAsync("42", new CollaborateurUpdateCommand()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION ARCHIVE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_BatikDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.CollaborateurRows = [BatikCollaborateurDict(42, "Dupont", "Jean")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync("42");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_OmikaDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.CollaborateurRows = [OmikaCollaborateurDict(42, "Dupont", "Jean")];
|
||||
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikCollaborateurRepository(batikConnection);
|
||||
var omikaRepo = new OmikaCollaborateurRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveCollaborateurRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.ArchiveAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RÉGRESSION : GUID Collaborateur TRONQUÉ (32 caractères, WinDev)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetUtilisateurByCollaborateurAsync_Omika_GuidTronque_InterrogeToutesLesFormes()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
// GUID physique stocké TRONQUÉ à 32 caractères (WinDev a omis les 4
|
||||
// derniers hexadécimaux), comme observé en base réelle.
|
||||
connection.CollaborateurRows =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["CO_PKID"] = 281474976710675L,
|
||||
["GUID"] = "62224eca-4565-4f47-9197-5c743940"
|
||||
}
|
||||
];
|
||||
|
||||
var repo = new OmikaCollaborateurRepository(connection);
|
||||
|
||||
// Identifiant canonique (forme « D » complétée par PhysicalGuidParser : + "0000").
|
||||
await repo.GetUtilisateurByCollaborateurAsync("62224eca-4565-4f47-9197-5c7439400000");
|
||||
|
||||
// La résolution doit tester TOUTES les formes de stockage (D, N, tronquée),
|
||||
// pas seulement la forme « D » (sinon le GUID tronqué ne matcherait jamais).
|
||||
string sqlCollaborateur = connection.AllSql.First(
|
||||
sql => sql.Contains("FROM Collaborateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("GUID IN (", sqlCollaborateur);
|
||||
Assert.Contains("?, ?, ?", sqlCollaborateur);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_Omika_GuidTronque_InterrogeToutesLesFormes()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
// GUID physique stocké TRONQUÉ à 32 caractères (WinDev a omis les 4
|
||||
// derniers hexadécimaux), comme observé en base réelle.
|
||||
connection.CollaborateurRows =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["CO_PKID"] = 281474976710675L,
|
||||
["GUID"] = "62224eca-4565-4f47-9197-5c743940",
|
||||
["CO_Nom"] = "Dupont",
|
||||
["CO_Prenom"] = "Jean"
|
||||
}
|
||||
];
|
||||
|
||||
var repo = new OmikaCollaborateurRepository(connection);
|
||||
|
||||
// Identifiant canonique (forme « D » complétée par PhysicalGuidParser : + "0000").
|
||||
await repo.GetDetailsAsync("62224eca-4565-4f47-9197-5c7439400000");
|
||||
|
||||
// La lecture par identifiant doit tester TOUTES les formes de stockage
|
||||
// (D, N, tronquée) : un GUID = ? en forme « D » ne matcherait jamais un
|
||||
// GUID physique tronqué → « Collaborateur introuvable ».
|
||||
string sqlCollaborateur = connection.AllSql.First(
|
||||
sql => sql.Contains("FROM Collaborateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("GUID IN (", sqlCollaborateur);
|
||||
Assert.Contains("?, ?, ?", sqlCollaborateur);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Repository_ImplementsICollaborateurRepository()
|
||||
{
|
||||
Assert.True(typeof(ICollaborateurRepository).IsAssignableFrom(typeof(AdaptiveCollaborateurRepository)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoSql()
|
||||
{
|
||||
string source = ReadSourceFile(typeof(AdaptiveCollaborateurRepository));
|
||||
|
||||
Assert.DoesNotContain("SELECT ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("FROM ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("WHERE ", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoOdbcTypes()
|
||||
{
|
||||
var type = typeof(AdaptiveCollaborateurRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoHfsqlRowReferences()
|
||||
{
|
||||
var type = typeof(AdaptiveCollaborateurRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Name.Contains("Row", StringComparison.OrdinalIgnoreCase)
|
||||
&& t.Namespace is not null
|
||||
&& t.Namespace.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_DoesNotDependOnHfsqlConnection()
|
||||
{
|
||||
var type = typeof(AdaptiveCollaborateurRepository);
|
||||
|
||||
bool hasHfsqlConnectionCtor = type.GetConstructors()
|
||||
.Any(c => c.GetParameters()
|
||||
.Any(p => typeof(IHfsqlConnection).IsAssignableFrom(p.ParameterType)));
|
||||
|
||||
Assert.False(hasHfsqlConnectionCtor,
|
||||
"AdaptiveCollaborateurRepository ne doit pas dépendre directement de IHfsqlConnection.");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static IEnumerable<Type> GetPublicSurfaceTypes(Type type)
|
||||
{
|
||||
var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
||||
.Select(m => m.DeclaringType)
|
||||
.Where(t => t is not null)
|
||||
.Cast<Type>();
|
||||
|
||||
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SelectMany(m => m.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(m => m.ReturnType);
|
||||
|
||||
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(p => p.PropertyType);
|
||||
|
||||
var ctorParams = type.GetConstructors()
|
||||
.SelectMany(c => c.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties).Concat(ctorParams);
|
||||
}
|
||||
|
||||
private static string ReadSourceFile(Type type)
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
dir = dir.Parent;
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(root, "src", "Omika.Data", "Repositories", type.Name + ".cs"));
|
||||
}
|
||||
|
||||
// --- Dictionnaires de lignes physiques ----------------------------------
|
||||
|
||||
private static Dictionary<string, object?> BatikCollaborateurDict(
|
||||
long pkid, string? nom, string? prenom)
|
||||
=> new()
|
||||
{
|
||||
["CL_PKID"] = pkid,
|
||||
["CL_Nom"] = nom,
|
||||
["CL_Prenom"] = prenom,
|
||||
["CL_Code"] = "JD",
|
||||
["CL_EMail"] = "jean@example.com",
|
||||
["CL_Telephone"] = "0102030405",
|
||||
["CL_IDCivilite"] = 0L,
|
||||
["CL_IDStatut"] = 0L,
|
||||
["CL_IDFonction"] = 0L,
|
||||
["CL_IDSociete"] = 0L,
|
||||
["CL_CouleurPlanning"] = "#FF0000",
|
||||
["CL_Actif"] = true,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> OmikaCollaborateurDict(
|
||||
long pkid, string? nom, string? prenom)
|
||||
=> new()
|
||||
{
|
||||
["CL_PKID"] = pkid,
|
||||
["CL_Nom"] = nom,
|
||||
["CL_Prenom"] = prenom,
|
||||
["CL_Code"] = "JD",
|
||||
["CL_EMail"] = "jean@example.com",
|
||||
["CL_Telephone"] = "0102030405",
|
||||
["CL_CleCivilite"] = null,
|
||||
["CL_CleStatut"] = null,
|
||||
["CL_CleFonction"] = null,
|
||||
["CL_IdSociete"] = 0L,
|
||||
["CL_CouleurPlanning"] = "#FF0000",
|
||||
["CL_Actif"] = true,
|
||||
["GUID"] = null,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// SpyingHfsqlConnection
|
||||
// =========================================================================
|
||||
|
||||
private sealed class SpyingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public int QueryCount { get; private set; }
|
||||
public bool ThrowOnQuery { get; set; }
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public IReadOnlyList<string> AllSql => _allSql;
|
||||
private readonly List<string> _allSql = [];
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
|
||||
|
||||
private void Record(string sql, IReadOnlyList<object?>? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
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, parameters, cancellationToken);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
||||
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ContainsFrom(sql, "Collaborateur") || ContainsFrom(sql, "Collaborateurs"))
|
||||
return CollaborateurRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MinimalFakeHfsqlConnection (DI)
|
||||
// =========================================================================
|
||||
|
||||
private sealed class MinimalFakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
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)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Interventions;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Interventions;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="AdaptiveInterventionRepository"/> : délégation,
|
||||
/// comportement Unknown, propagation d'erreurs, architecture, et DI.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class AdaptiveInterventionRepositoryTests : IDisposable
|
||||
{
|
||||
public AdaptiveInterventionRepositoryTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION SEARCH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikDetected_DelegatesToBatikRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [BatikInterventionDict(1, "Test", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new InterventionSearchCriteria { Titre = "Test" });
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OmikaDetected_DelegatesToOmikaRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.InterventionRows = [OmikaInterventionDict(1, "Test", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new InterventionSearchCriteria { Titre = "Test" });
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new InterventionSearchCriteria()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_EmptyCriteria_DoesNotCrash()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<Intervention> result = await adaptive.SearchAsync(new InterventionSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION DETAIL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [BatikInterventionDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
Intervention? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.InterventionRows = [OmikaInterventionDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
Intervention? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_NullFromRepository_ReturnsNull()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
Intervention? result = await adaptive.GetDetailsAsync("999");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROPAGATION D'ERREURS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new InterventionSearchCriteria()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DI TESTS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersBatikInterventionRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(BatikInterventionRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersOmikaInterventionRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(OmikaInterventionRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersIInterventionRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(IInterventionRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_IInterventionRepositoryPointsToAdaptiveInterventionRepository()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(IInterventionRepository));
|
||||
Assert.Equal(typeof(AdaptiveInterventionRepository), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_OnlyOneIInterventionRepositoryRegistered()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(IInterventionRepository)).ToList();
|
||||
Assert.Single(descriptors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOmikaData_ResolvesAllInterventionRepositories_WithFakeConnection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddOmikaData();
|
||||
|
||||
var oldDescriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
|
||||
services.Remove(oldDescriptor);
|
||||
services.AddScoped<IHfsqlConnection, MinimalFakeHfsqlConnection>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var interventionRepo = scope.ServiceProvider.GetRequiredService<IInterventionRepository>();
|
||||
Assert.IsType<AdaptiveInterventionRepository>(interventionRepo);
|
||||
|
||||
var batikRepo = scope.ServiceProvider.GetRequiredService<BatikInterventionRepository>();
|
||||
Assert.NotNull(batikRepo);
|
||||
|
||||
var omikaRepo = scope.ServiceProvider.GetRequiredService<OmikaInterventionRepository>();
|
||||
Assert.NotNull(omikaRepo);
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION UPDATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [BatikInterventionDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync("42", new InterventionUpdateCommand { Titre = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.InterventionRows = [OmikaInterventionDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync("42", new InterventionUpdateCommand { Titre = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.UpdateAsync("42", new InterventionUpdateCommand()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION ARCHIVE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_BatikDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.InterventionRows = [BatikInterventionDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync("42");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_OmikaDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.InterventionRows = [OmikaInterventionDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync("42");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikInterventionRepository(batikConnection);
|
||||
var omikaRepo = new OmikaInterventionRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveInterventionRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.ArchiveAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Repository_ImplementsIInterventionRepository()
|
||||
{
|
||||
Assert.True(typeof(IInterventionRepository).IsAssignableFrom(typeof(AdaptiveInterventionRepository)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoSql()
|
||||
{
|
||||
string source = ReadSourceFile(typeof(AdaptiveInterventionRepository));
|
||||
|
||||
Assert.DoesNotContain("SELECT ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("FROM ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("WHERE ", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoOdbcTypes()
|
||||
{
|
||||
var type = typeof(AdaptiveInterventionRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoHfsqlRowReferences()
|
||||
{
|
||||
var type = typeof(AdaptiveInterventionRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Name.Contains("Row", StringComparison.OrdinalIgnoreCase)
|
||||
&& t.Namespace is not null
|
||||
&& t.Namespace.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_DoesNotDependOnHfsqlConnection()
|
||||
{
|
||||
var type = typeof(AdaptiveInterventionRepository);
|
||||
|
||||
bool hasHfsqlConnectionCtor = type.GetConstructors()
|
||||
.Any(c => c.GetParameters()
|
||||
.Any(p => typeof(IHfsqlConnection).IsAssignableFrom(p.ParameterType)));
|
||||
|
||||
Assert.False(hasHfsqlConnectionCtor,
|
||||
"AdaptiveInterventionRepository ne doit pas dépendre directement de IHfsqlConnection.");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static IEnumerable<Type> GetPublicSurfaceTypes(Type type)
|
||||
{
|
||||
var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
||||
.Select(m => m.DeclaringType)
|
||||
.Where(t => t is not null)
|
||||
.Cast<Type>();
|
||||
|
||||
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SelectMany(m => m.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(m => m.ReturnType);
|
||||
|
||||
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(p => p.PropertyType);
|
||||
|
||||
var ctorParams = type.GetConstructors()
|
||||
.SelectMany(c => c.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties).Concat(ctorParams);
|
||||
}
|
||||
|
||||
private static string ReadSourceFile(Type type)
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
dir = dir.Parent;
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(root, "src", "Omika.Data", "Repositories", type.Name + ".cs"));
|
||||
}
|
||||
|
||||
// --- Dictionnaires de lignes physiques ----------------------------------
|
||||
|
||||
private static Dictionary<string, object?> BatikInterventionDict(
|
||||
long pkid, string? titre, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["IN_PKID"] = pkid,
|
||||
["IN_Titre"] = titre,
|
||||
["IN_Reference"] = reference,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> OmikaInterventionDict(
|
||||
long pkid, string? titre, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["IN_PKID"] = pkid,
|
||||
["IN_Titre"] = titre,
|
||||
["IN_Reference"] = reference,
|
||||
["Archive"] = false,
|
||||
["GUID"] = null,
|
||||
["ModifDate"] = DateTime.MinValue,
|
||||
["CreationDate"] = DateTime.MinValue
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// SpyingHfsqlConnection
|
||||
// =========================================================================
|
||||
|
||||
private sealed class SpyingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public int QueryCount { get; private set; }
|
||||
public bool ThrowOnQuery { get; set; }
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public IReadOnlyList<string> AllSql => _allSql;
|
||||
private readonly List<string> _allSql = [];
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> InterventionRows { get; set; } = [];
|
||||
|
||||
private void Record(string sql, IReadOnlyList<object?>? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
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, parameters, cancellationToken);
|
||||
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
||||
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
||||
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ContainsFrom(sql, "Intervention")) return InterventionRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MinimalFakeHfsqlConnection (DI)
|
||||
// =========================================================================
|
||||
|
||||
private sealed class MinimalFakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
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)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Planning;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="AdaptiveRendezVousRepository"/> : délégation,
|
||||
/// comportement Unknown, propagation d'erreurs, architecture, et DI.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class AdaptiveRendezVousRepositoryTests : IDisposable
|
||||
{
|
||||
public AdaptiveRendezVousRepositoryTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION SEARCH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikDetected_DelegatesToBatikRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [BatikRendezVousDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new RendezVousSearchCriteria { Search = "Réunion" });
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OmikaDetected_DelegatesToOmikaRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.RendezVousRows = [OmikaRendezVousDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new RendezVousSearchCriteria { Search = "Réunion" });
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new RendezVousSearchCriteria()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_EmptyCriteria_DoesNotCrash()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<RendezVous> result = await adaptive.SearchAsync(new RendezVousSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION DETAIL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [BatikRendezVousDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
RendezVous? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.RendezVousRows = [OmikaRendezVousDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
RendezVous? result = await adaptive.GetDetailsAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_NullFromRepository_ReturnsNull()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
RendezVous? result = await adaptive.GetDetailsAsync("999");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROPAGATION D'ERREURS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new RendezVousSearchCriteria()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_OmikaInfrastructureError_Propagated()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection { ThrowOnQuery = true };
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetDetailsAsync("42"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DI TESTS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersBatikRendezVousRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(BatikRendezVousRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersOmikaRendezVousRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(OmikaRendezVousRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersIRendezVousRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(IRendezVousRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_IRendezVousRepositoryPointsToAdaptiveRendezVousRepository()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(IRendezVousRepository));
|
||||
Assert.Equal(typeof(AdaptiveRendezVousRepository), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_OnlyOneIRendezVousRepositoryRegistered()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(IRendezVousRepository)).ToList();
|
||||
Assert.Single(descriptors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOmikaData_ResolvesAllRendezVousRepositories_WithFakeConnection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddOmikaData();
|
||||
|
||||
var oldDescriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
|
||||
services.Remove(oldDescriptor);
|
||||
services.AddScoped<IHfsqlConnection, MinimalFakeHfsqlConnection>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var rendezVousRepo = scope.ServiceProvider.GetRequiredService<IRendezVousRepository>();
|
||||
Assert.IsType<AdaptiveRendezVousRepository>(rendezVousRepo);
|
||||
|
||||
var batikRepo = scope.ServiceProvider.GetRequiredService<BatikRendezVousRepository>();
|
||||
Assert.NotNull(batikRepo);
|
||||
|
||||
var omikaRepo = scope.ServiceProvider.GetRequiredService<OmikaRendezVousRepository>();
|
||||
Assert.NotNull(omikaRepo);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION UPDATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [BatikRendezVousDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync("42", new RendezVousUpdateCommand { Titre = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.RendezVousRows = [OmikaRendezVousDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.UpdateAsync(Guid.NewGuid().ToString("D"), new RendezVousUpdateCommand { Titre = "Updated" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.UpdateAsync("42", new RendezVousUpdateCommand()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION CANCEL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_BatikDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [BatikRendezVousDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.CancelAsync("42", new AnnulationRendezVousCommand());
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_OmikaDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.RendezVousRows = [OmikaRendezVousDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.CancelAsync(Guid.NewGuid().ToString("D"), new AnnulationRendezVousCommand());
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.CancelAsync("42", new AnnulationRendezVousCommand()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION ARCHIVE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_BatikDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.RendezVousRows = [BatikRendezVousDict(42, "Dupont", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync("42");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_OmikaDetected_Existing_ReturnsTrue()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.RendezVousRows = [OmikaRendezVousDict(42, "Dupont", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
bool result = await adaptive.ArchiveAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikRendezVousRepository(batikConnection);
|
||||
var omikaRepo = new OmikaRendezVousRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveRendezVousRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.ArchiveAsync("42"));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Repository_ImplementsIRendezVousRepository()
|
||||
{
|
||||
Assert.True(typeof(IRendezVousRepository).IsAssignableFrom(typeof(AdaptiveRendezVousRepository)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoSql()
|
||||
{
|
||||
string source = ReadSourceFile(typeof(AdaptiveRendezVousRepository));
|
||||
|
||||
Assert.DoesNotContain("SELECT ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("FROM ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("WHERE ", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoOdbcTypes()
|
||||
{
|
||||
var type = typeof(AdaptiveRendezVousRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoHfsqlRowReferences()
|
||||
{
|
||||
var type = typeof(AdaptiveRendezVousRepository);
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Name.Contains("Row", StringComparison.OrdinalIgnoreCase)
|
||||
&& t.Namespace is not null
|
||||
&& t.Namespace.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_DoesNotDependOnHfsqlConnection()
|
||||
{
|
||||
var type = typeof(AdaptiveRendezVousRepository);
|
||||
|
||||
bool hasHfsqlConnectionCtor = type.GetConstructors()
|
||||
.Any(c => c.GetParameters()
|
||||
.Any(p => typeof(IHfsqlConnection).IsAssignableFrom(p.ParameterType)));
|
||||
|
||||
Assert.False(hasHfsqlConnectionCtor,
|
||||
"AdaptiveRendezVousRepository ne doit pas dépendre directement de IHfsqlConnection.");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static IEnumerable<Type> GetPublicSurfaceTypes(Type type)
|
||||
{
|
||||
var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
||||
.Select(m => m.DeclaringType)
|
||||
.Where(t => t is not null)
|
||||
.Cast<Type>();
|
||||
|
||||
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SelectMany(m => m.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(m => m.ReturnType);
|
||||
|
||||
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(p => p.PropertyType);
|
||||
|
||||
var ctorParams = type.GetConstructors()
|
||||
.SelectMany(c => c.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties).Concat(ctorParams);
|
||||
}
|
||||
|
||||
private static string ReadSourceFile(Type type)
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
dir = dir.Parent;
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(root, "src", "Omika.Data", "Repositories", type.Name + ".cs"));
|
||||
}
|
||||
|
||||
// --- Dictionnaires de lignes physiques ----------------------------------
|
||||
|
||||
private static Dictionary<string, object?> BatikRendezVousDict(
|
||||
long pkid, string? objet, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["PL_PKID"] = pkid,
|
||||
["PL_Objet"] = objet,
|
||||
["PL_DateDebut"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["PL_DateFin"] = new DateTime(2026, 1, 15, 10, 0, 0),
|
||||
["PL_IDTypeActivite"] = 0L,
|
||||
["PL_IDStatut"] = 0L,
|
||||
["PL_IDCollaborateur"] = 0L,
|
||||
["PL_IDOrigineAnnul"] = 0UL,
|
||||
["PL_IDMotifAnnul"] = 0UL,
|
||||
["PL_Prive"] = false,
|
||||
["PL_Superposable"] = false,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> OmikaRendezVousDict(
|
||||
long pkid, string? objet, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["RV_PKID"] = pkid,
|
||||
["RV_Objet"] = objet,
|
||||
["RV_Date"] = new DateOnly(2026, 1, 15),
|
||||
["RV_CleType"] = null,
|
||||
["RV_CleStatut"] = null,
|
||||
["RV_IdLieu"] = 0UL,
|
||||
["RV_IdDossier"] = 0L,
|
||||
["RV_IdInter"] = 0L,
|
||||
["RV_IdSociete"] = 0L,
|
||||
["RV_CleOrigineAnnu"] = null,
|
||||
["RV_CleMotifAnnu"] = null,
|
||||
["GUID"] = null,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// SpyingHfsqlConnection
|
||||
// =========================================================================
|
||||
|
||||
private sealed class SpyingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public int QueryCount { get; private set; }
|
||||
public bool ThrowOnQuery { get; set; }
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public IReadOnlyList<string> AllSql => _allSql;
|
||||
private readonly List<string> _allSql = [];
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> RendezVousRows { get; set; } = [];
|
||||
|
||||
private void Record(string sql, IReadOnlyList<object?>? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
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, parameters, cancellationToken);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
||||
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ContainsFrom(sql, "Planning_RDV") || ContainsFrom(sql, "RDV_Planning"))
|
||||
return RendezVousRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MinimalFakeHfsqlConnection (DI)
|
||||
// =========================================================================
|
||||
|
||||
private sealed class MinimalFakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
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)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
using System.Data.Common;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Planning;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Core.Services;
|
||||
using Omika.Data;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="AdaptiveSuiviTempsRepository"/> : délégation,
|
||||
/// comportement Unknown, mapping Batik/Omika, délégation du service, et DI.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class AdaptiveSuiviTempsRepositoryTests : IDisposable
|
||||
{
|
||||
public AdaptiveSuiviTempsRepositoryTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION SEARCH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikDetected_DelegatesToBatikRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.SuiviTempsRows = [BatikSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OmikaDetected_DelegatesToOmikaRepository()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.SuiviTempsRows = [OmikaSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
await adaptive.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.SearchAsync(new SuiviTempsSearchCriteria()));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_EmptyCriteria_DoesNotCrash()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.SuiviTempsRows = [];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<SuiviTemps> result = await adaptive.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAPPING BATIK
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_BatikMapping_ProducesCanonicalSuiviTemps()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.SuiviTempsRows = [BatikSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<SuiviTemps> result = await adaptive.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("Réunion", result.Items[0].Description);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAPPING OMIKA
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OmikaMapping_ProducesCanonicalSuiviTemps()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.SuiviTempsRows = [OmikaSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
PagedResult<SuiviTemps> result = await adaptive.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("Réunion", result.Items[0].Description);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION GET BY ID
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.SuiviTempsRows = [BatikSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
SuiviTemps? result = await adaptive.GetByIdAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.SuiviTempsRows = [OmikaSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
SuiviTemps? result = await adaptive.GetByIdAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.GetByIdAsync(Guid.NewGuid().ToString("D")));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELEGATION CREATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_BatikDetected_DelegatesToBatik()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
batikConnection.SuiviTempsRows = [BatikSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
SuiviTemps result = await adaptive.CreateAsync(
|
||||
new SuiviTempsCreateCommand { Date = DateTime.Now, DureeMinutes = 60 },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(batikConnection.QueryCount > 0, "Batik aurait dû exécuter des requêtes.");
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_OmikaDetected_DelegatesToOmika()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
omikaConnection.SuiviTempsRows = [OmikaSuiviTempsDict(1, "Réunion", "INT-001")];
|
||||
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
SuiviTemps result = await adaptive.CreateAsync(
|
||||
new SuiviTempsCreateCommand { Date = DateTime.Now, DureeMinutes = 60 },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.True(omikaConnection.QueryCount > 0, "Omika aurait dû exécuter des requêtes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_UnknownDetected_ThrowsInvalidOperationException()
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
|
||||
var batikConnection = new SpyingHfsqlConnection();
|
||||
var omikaConnection = new SpyingHfsqlConnection();
|
||||
var batikRepo = new BatikSuiviTempsRepository(batikConnection);
|
||||
var omikaRepo = new OmikaSuiviTempsRepository(omikaConnection);
|
||||
var adaptive = new AdaptiveSuiviTempsRepository(batikRepo, omikaRepo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => adaptive.CreateAsync(
|
||||
new SuiviTempsCreateCommand { Date = DateTime.Now, DureeMinutes = 60 }));
|
||||
|
||||
Assert.Contains("not been detected", ex.Message);
|
||||
Assert.Equal(0, batikConnection.QueryCount);
|
||||
Assert.Equal(0, omikaConnection.QueryCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SERVICE DELEGATION
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Service_SearchAsync_DelegatesToRepository()
|
||||
{
|
||||
var repo = new FakeSuiviTempsRepository();
|
||||
var service = new SuiviTempsService(repo);
|
||||
|
||||
PagedResult<SuiviTempsDto> result = await service.SearchAsync(new SuiviTempsSearchCriteria());
|
||||
|
||||
Assert.True(repo.SearchCalled);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_GetByIdAsync_DelegatesToRepository()
|
||||
{
|
||||
var repo = new FakeSuiviTempsRepository();
|
||||
var service = new SuiviTempsService(repo);
|
||||
|
||||
SuiviTempsDto? result = await service.GetByIdAsync("abc");
|
||||
|
||||
Assert.True(repo.GetByIdCalled);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_CreateAsync_DelegatesToRepository()
|
||||
{
|
||||
var repo = new FakeSuiviTempsRepository();
|
||||
var service = new SuiviTempsService(repo);
|
||||
|
||||
SuiviTempsDto result = await service.CreateAsync(
|
||||
new CreateSuiviTempsRequest { Date = DateTime.Now, DureeMinutes = 60 });
|
||||
|
||||
Assert.True(repo.CreateCalled);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DI TESTS
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersBatikSuiviTempsRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(BatikSuiviTempsRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersOmikaSuiviTempsRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(OmikaSuiviTempsRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersISuiviTempsRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ISuiviTempsRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_ISuiviTempsRepositoryPointsToAdaptiveSuiviTempsRepository()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ISuiviTempsRepository));
|
||||
Assert.Equal(typeof(AdaptiveSuiviTempsRepository), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersISuiviTempsServiceAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ISuiviTempsService));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_ISuiviTempsServicePointsToSuiviTempsService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(ISuiviTempsService));
|
||||
Assert.Equal(typeof(SuiviTempsService), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_OnlyOneISuiviTempsRepositoryRegistered()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(ISuiviTempsRepository)).ToList();
|
||||
Assert.Single(descriptors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOmikaData_ResolvesAllSuiviTempsRepositories_WithFakeConnection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddOmikaData();
|
||||
|
||||
var oldDescriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
|
||||
services.Remove(oldDescriptor);
|
||||
services.AddScoped<IHfsqlConnection, MinimalFakeHfsqlConnection>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var suiviTempsRepo = scope.ServiceProvider.GetRequiredService<ISuiviTempsRepository>();
|
||||
Assert.IsType<AdaptiveSuiviTempsRepository>(suiviTempsRepo);
|
||||
|
||||
var batikRepo = scope.ServiceProvider.GetRequiredService<BatikSuiviTempsRepository>();
|
||||
Assert.NotNull(batikRepo);
|
||||
|
||||
var omikaRepo = scope.ServiceProvider.GetRequiredService<OmikaSuiviTempsRepository>();
|
||||
Assert.NotNull(omikaRepo);
|
||||
|
||||
var service = scope.ServiceProvider.GetRequiredService<ISuiviTempsService>();
|
||||
Assert.IsType<SuiviTempsService>(service);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Repository_ImplementsISuiviTempsRepository()
|
||||
{
|
||||
Assert.True(typeof(ISuiviTempsRepository).IsAssignableFrom(typeof(AdaptiveSuiviTempsRepository)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_ContainsNoSql()
|
||||
{
|
||||
string source = ReadSourceFile(typeof(AdaptiveSuiviTempsRepository));
|
||||
|
||||
Assert.DoesNotContain("SELECT ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("FROM ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("WHERE ", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repository_DoesNotDependOnHfsqlConnection()
|
||||
{
|
||||
var type = typeof(AdaptiveSuiviTempsRepository);
|
||||
|
||||
bool hasHfsqlConnectionCtor = type.GetConstructors()
|
||||
.Any(c => c.GetParameters()
|
||||
.Any(p => typeof(IHfsqlConnection).IsAssignableFrom(p.ParameterType)));
|
||||
|
||||
Assert.False(hasHfsqlConnectionCtor,
|
||||
"AdaptiveSuiviTempsRepository ne doit pas dépendre directement de IHfsqlConnection.");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static string ReadSourceFile(Type type)
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
dir = dir.Parent;
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(root, "src", "Omika.Data", "Repositories", type.Name + ".cs"));
|
||||
}
|
||||
|
||||
// --- Dictionnaires de lignes physiques ----------------------------------
|
||||
|
||||
private static Dictionary<string, object?> BatikSuiviTempsDict(
|
||||
long pkid, string? libelle, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["SDT_PKID"] = pkid,
|
||||
["SDT_DateDebut"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["SDT_IDIntervention"] = 0L,
|
||||
["SDT_IDCollaborateur"] = 0L,
|
||||
["SDT_IDPartenaire"] = 0L,
|
||||
["SDT_NbMinutes"] = 60,
|
||||
["SDT_Libelle"] = libelle,
|
||||
["SDT_IDTypeTemps"] = 0L,
|
||||
["SDT_IDRdv"] = 0L,
|
||||
["SDT_Ponderation"] = 0L,
|
||||
["SDT_IDImport"] = 0L,
|
||||
["SDT_DateHeureFin"] = new DateTime(2026, 1, 15, 10, 0, 0),
|
||||
["GUID"] = Guid.NewGuid().ToString("D"),
|
||||
["SDT_IDTypeRdv"] = 0L,
|
||||
["SDT_Controle"] = false,
|
||||
["SDT_IDGroupe"] = 0L,
|
||||
["CreationDate"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["CreationUser"] = 0L,
|
||||
["ModifDate"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["ModifUser"] = 0L
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> OmikaSuiviTempsDict(
|
||||
long pkid, string? libelle, string? reference)
|
||||
=> new()
|
||||
{
|
||||
["SDT_PKID"] = pkid,
|
||||
["SDT_DateDebut"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["SDT_IDIntervention"] = 0L,
|
||||
["SDT_IDCollaborateur"] = 0L,
|
||||
["SDT_IDPartenaire"] = 0L,
|
||||
["SDT_NbMinutes"] = 60,
|
||||
["SDT_Libelle"] = libelle,
|
||||
["SDT_IDTypeTemps"] = 0L,
|
||||
["SDT_IDRdv"] = 0L,
|
||||
["SDT_Ponderation"] = 0L,
|
||||
["SDT_IDImport"] = 0L,
|
||||
["SDT_DateHeureFin"] = new DateTime(2026, 1, 15, 10, 0, 0),
|
||||
["GUID"] = Guid.NewGuid().ToString("D"),
|
||||
["SDT_IDTypeRdv"] = 0L,
|
||||
["SDT_Controle"] = false,
|
||||
["SDT_IDGroupe"] = 0L,
|
||||
["CreationDate"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["CreationUser"] = 0L,
|
||||
["ModifDate"] = new DateTime(2026, 1, 15, 9, 0, 0),
|
||||
["ModifUser"] = 0L,
|
||||
["SDT_CleType"] = null,
|
||||
["SDT_CleTypeRDV"] = null,
|
||||
["SDT_NoteColb"] = null,
|
||||
["SDT_NoteAdmin"] = null,
|
||||
["SDT_GUID_Rdv_Colb"] = null
|
||||
};
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// FakeSuiviTempsRepository (service)
|
||||
// =========================================================================
|
||||
|
||||
private sealed class FakeSuiviTempsRepository : ISuiviTempsRepository
|
||||
{
|
||||
public bool SearchCalled { get; private set; }
|
||||
public bool GetByIdCalled { get; private set; }
|
||||
public bool CreateCalled { get; private set; }
|
||||
|
||||
public Task<PagedResult<SuiviTemps>> SearchAsync(
|
||||
SuiviTempsSearchCriteria criteria, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCalled = true;
|
||||
return Task.FromResult(new PagedResult<SuiviTemps>([], 1, 50, 0, 0));
|
||||
}
|
||||
|
||||
public Task<SuiviTemps?> GetByIdAsync(
|
||||
string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetByIdCalled = true;
|
||||
return Task.FromResult<SuiviTemps?>(null);
|
||||
}
|
||||
|
||||
public Task<SuiviTemps> CreateAsync(
|
||||
SuiviTempsCreateCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CreateCalled = true;
|
||||
return Task.FromResult(new SuiviTemps
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Date = command.Date,
|
||||
DureeMinutes = command.DureeMinutes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SpyingHfsqlConnection
|
||||
// =========================================================================
|
||||
|
||||
private sealed class SpyingHfsqlConnection : IHfsqlConnection, IHfsqlWriteConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public DbTransaction? ActiveTransaction => null;
|
||||
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public bool ThrowOnQuery { get; set; }
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public IReadOnlyList<string> AllSql => _allSql;
|
||||
private readonly List<string> _allSql = [];
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> SuiviTempsRows { get; set; } = [];
|
||||
|
||||
private void Record(string sql, IReadOnlyList<object?>? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
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, parameters, cancellationToken);
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters, cancellationToken);
|
||||
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
||||
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
|
||||
public Task BeginTransactionAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task RollbackAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<long?>> ExecuteInTransactionAsync(
|
||||
IReadOnlyList<HfsqlWriteOperation> operations,
|
||||
CancellationToken cancellationToken = default,
|
||||
DbTransaction? transaction = null)
|
||||
{
|
||||
foreach (HfsqlWriteOperation operation in operations)
|
||||
{
|
||||
Record(operation.Sql, operation.Parameters, cancellationToken);
|
||||
}
|
||||
|
||||
// Retourne une identité auto-générée pour chaque opération.
|
||||
return Task.FromResult<IReadOnlyList<long?>>(
|
||||
operations.Select(_ => (long?)1L).ToList());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
|
||||
{
|
||||
if (ContainsFrom(sql, "SuiviDesTemps"))
|
||||
return SuiviTempsRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MinimalFakeHfsqlConnection (DI)
|
||||
// =========================================================================
|
||||
|
||||
private sealed class MinimalFakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
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)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Data;
|
||||
using Omika.Data.Repositories;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du point d'accès unique aux paramètres applicatifs du client
|
||||
/// (table <c>ParametreApplication</c>).
|
||||
///
|
||||
/// Vérifie qu'il n'existe PAS deux systèmes parallèles : les repositories
|
||||
/// documents consomment la même abstraction que le service de bootstrap au lieu
|
||||
/// de dupliquer leur propre lecture SQL.
|
||||
/// </summary>
|
||||
public class ApplicationParameterRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersBatikApplicationParameterRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(BatikApplicationParameterRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_RegistersOmikaApplicationParameterRepositoryAsScoped()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(OmikaApplicationParameterRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_IApplicationParameterRepositoryPointsToAdaptive()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOmikaData();
|
||||
|
||||
var descriptor = services.Single(d => d.ServiceType == typeof(IApplicationParameterRepository));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(typeof(AdaptiveApplicationParameterRepository), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOmikaData_PhysicalRepositoriesDoNotDependOnTheAbstraction()
|
||||
{
|
||||
// Les implémentations Batik/Omika ne doivent pas dépendre de
|
||||
// IApplicationParameterRepository : la sélection du schéma est le rôle de
|
||||
// l'adaptatif, il ne doit pas y avoir de résolution récursive.
|
||||
foreach (Type implementation in new[]
|
||||
{
|
||||
typeof(BatikApplicationParameterRepository),
|
||||
typeof(OmikaApplicationParameterRepository)
|
||||
})
|
||||
{
|
||||
var constructor = Assert.Single(implementation.GetConstructors());
|
||||
Assert.DoesNotContain(
|
||||
constructor.GetParameters(),
|
||||
p => p.ParameterType == typeof(IApplicationParameterRepository));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(BatikDocumentRepository))]
|
||||
[InlineData(typeof(OmikaDocumentRepository))]
|
||||
public void DocumentRepositories_UtilisentLePointDAccesUnique(Type repositoryType)
|
||||
{
|
||||
// Les repositories documents lisent les paramètres applicatifs
|
||||
// (racine et format des répertoires de documents) via l'abstraction
|
||||
// commune : plus aucune lecture ParametreApplication dupliquée.
|
||||
var constructor = Assert.Single(repositoryType.GetConstructors());
|
||||
|
||||
Assert.Contains(
|
||||
constructor.GetParameters(),
|
||||
p => p.ParameterType == typeof(IApplicationParameterRepository));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AucuneLectureParametreApplicationHorsDuRepositoryDedie()
|
||||
{
|
||||
// Garde-fou anti-architecture-parallèle : la seule requête SQL sur
|
||||
// ParametreApplication doit vivre dans le repository dédié.
|
||||
string racine = TrouverRacineSources();
|
||||
string[] fichiers = Directory.GetFiles(Path.Combine(racine, "src"), "*.cs", SearchOption.AllDirectories);
|
||||
var fautifs = new List<string>();
|
||||
|
||||
foreach (string fichier in fichiers)
|
||||
{
|
||||
if (fichier.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
|| fichier.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string contenu = File.ReadAllText(fichier);
|
||||
if (!contenu.Contains("FROM ParametreApplication", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string nom = Path.GetFileName(fichier);
|
||||
if (nom is not ("BatikApplicationParameterRepository.cs" or "OmikaApplicationParameterRepository.cs"))
|
||||
{
|
||||
fautifs.Add(nom);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
fautifs.Count == 0,
|
||||
$"Lecture directe de ParametreApplication hors du repository dédié : {string.Join(", ", fautifs)}");
|
||||
}
|
||||
|
||||
private static string TrouverRacineSources()
|
||||
{
|
||||
var repertoire = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (repertoire is not null && !Directory.Exists(Path.Combine(repertoire.FullName, "src")))
|
||||
{
|
||||
repertoire = repertoire.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(repertoire);
|
||||
return repertoire!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Omika.Core.Domain.Entities.Security;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du repository d'authentification Back-Office.
|
||||
/// Vérifie que la requête est paramétrée (aucune concaténation du login ni du
|
||||
/// hash), que la comparaison du login est insensible à la casse, que le hash est
|
||||
/// comparé tel quel, et que la ligne jointe est correctement mappée.
|
||||
/// </summary>
|
||||
public class BackOfficeAuthRepositoryTests
|
||||
{
|
||||
private const string Login = "DFO";
|
||||
private const string Hash = "FAKE-HASH-0123456789ABCDEF0123456789ABCDEF";
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLoginAndPasswordHashAsync_RequeteParametree_RetourneUtilisateur()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open)
|
||||
{
|
||||
QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["UT_PKID"] = 42L,
|
||||
["UT_Login"] = Login,
|
||||
["UT_IDCollaborateur"] = 7L,
|
||||
["UT_AccesBO"] = true,
|
||||
["CO_PKID"] = 7L,
|
||||
["CO_AccesBO"] = true,
|
||||
["CO_Actif"] = true
|
||||
}
|
||||
]
|
||||
};
|
||||
var repository = new BackOfficeAuthRepository(connection);
|
||||
|
||||
BackOfficeAuthUser? user = await repository.GetByLoginAndPasswordHashAsync(Login, Hash);
|
||||
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal(42, user.UserId);
|
||||
Assert.Equal(Login, user.Login);
|
||||
Assert.Equal(7, user.CollaborateurId);
|
||||
Assert.Equal(7, user.CollaborateurPkId);
|
||||
Assert.True(user.UtilisateurAccesBO);
|
||||
Assert.True(user.CollaborateurAccesBO);
|
||||
Assert.True(user.CollaborateurActif);
|
||||
|
||||
// Requête paramétrée : login et hash ne sont jamais concaténés au SQL.
|
||||
Assert.Contains("UPPER(Utilisateur.UT_Login) = ?", connection.LastSql);
|
||||
Assert.Contains("Utilisateur.UT_MotDePasse = ?", connection.LastSql);
|
||||
Assert.Contains("LEFT JOIN Collaborateur", connection.LastSql);
|
||||
Assert.DoesNotContain(Login, connection.LastSql);
|
||||
Assert.DoesNotContain(Hash, connection.LastSql);
|
||||
|
||||
Assert.NotNull(connection.LastParameters);
|
||||
Assert.Equal(2, connection.LastParameters!.Count);
|
||||
Assert.Equal(Login.ToUpperInvariant(), connection.LastParameters[0]);
|
||||
Assert.Equal(Hash, connection.LastParameters[1]); // hash comparé tel quel
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLoginAndPasswordHashAsync_LoginMinuscules_EstUppercasedDansLeParametre()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open) { QueryResult = [] };
|
||||
var repository = new BackOfficeAuthRepository(connection);
|
||||
|
||||
await repository.GetByLoginAndPasswordHashAsync("dfo", Hash);
|
||||
|
||||
Assert.NotNull(connection.LastParameters);
|
||||
Assert.Equal("DFO", connection.LastParameters![0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLoginAndPasswordHashAsync_AucuneLigne_RetourneNull()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open) { QueryResult = [] };
|
||||
var repository = new BackOfficeAuthRepository(connection);
|
||||
|
||||
Assert.Null(await repository.GetByLoginAndPasswordHashAsync(Login, Hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLoginAndPasswordHashAsync_ChampsVides_RetourneNullSansRequete()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
var repository = new BackOfficeAuthRepository(connection);
|
||||
|
||||
Assert.Null(await repository.GetByLoginAndPasswordHashAsync(" ", Hash));
|
||||
Assert.Null(await repository.GetByLoginAndPasswordHashAsync(Login, " "));
|
||||
Assert.Equal(0, connection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLoginAndPasswordHashAsync_ColonnesQualifiees_RetourneUtilisateur()
|
||||
{
|
||||
// Le provider peut renvoyer les colonnes sous forme qualifiée
|
||||
// (ex. « Utilisateur.UT_PKID ») : le mapping doit rester insensible.
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open)
|
||||
{
|
||||
QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Utilisateur.UT_PKID"] = 42L,
|
||||
["Utilisateur.UT_Login"] = Login,
|
||||
["Utilisateur.UT_IDCollaborateur"] = 7L,
|
||||
["Utilisateur.UT_AccesBO"] = false,
|
||||
["Collaborateur.CO_PKID"] = 7L,
|
||||
["Collaborateur.CO_AccesBO"] = true,
|
||||
["Collaborateur.CO_Actif"] = true
|
||||
}
|
||||
]
|
||||
};
|
||||
var repository = new BackOfficeAuthRepository(connection);
|
||||
|
||||
BackOfficeAuthUser? user = await repository.GetByLoginAndPasswordHashAsync(Login, Hash);
|
||||
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal(42, user.UserId);
|
||||
Assert.Equal(7, user.CollaborateurId);
|
||||
Assert.True(user.CollaborateurAccesBO);
|
||||
}
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeHfsqlConnection(HfsqlConnectionState state) => State = state;
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public int QueryCount { get; private set; }
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> QueryResult { get; set; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueryCount++;
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
return Task.FromResult(QueryResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Services.Mobile;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
namespace Omika.Tests.Repositories.Batik;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de la création d'un compte rendu Batik
|
||||
/// (<see cref="BatikCompteRenduRepository.CreateAsync"/>) centrés sur la colonne
|
||||
/// <c>CR_IDCollaborateur</c> : aucun serveur HFSQL, aucun ODBC réel, aucun réseau.
|
||||
/// Un fake <see cref="IHfsqlConnection"/> enregistre le SQL et les paramètres, et
|
||||
/// retourne des dictionnaires selon la table interrogée.
|
||||
///
|
||||
/// Objectifs :
|
||||
/// <list type="bullet">
|
||||
/// <item>la colonne porte l'identité du collaborateur AUTEUR du compte rendu, et
|
||||
/// non plus systématiquement le collaborateur affecté à <c>Planning_RDV</c> ;</item>
|
||||
/// <item>le PK numérique du jeton mobile est repris tel quel, et le GUID
|
||||
/// synthétique réversible Batik est décodé (la table <c>Collaborateur</c> Batik
|
||||
/// ne porte aucune colonne GUID) ;</item>
|
||||
/// <item>à défaut d'auteur résolvable, le collaborateur affecté au rendez-vous
|
||||
/// reste le repli historique : la colonne n'est jamais laissée vide.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class BatikCompteRenduRepositoryTests
|
||||
{
|
||||
private const long RendezVousId = 42L;
|
||||
private const long CollaborateurDuRendezVous = 7L;
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurNumerique_RenseigneLAuteurEtNonLeCollaborateurDuRendezVous()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command("9"));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
// 9 (auteur) et non 7 (collaborateur affecté à Planning_RDV).
|
||||
Assert.Equal(9L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurGuidSynthetique_DecodeLePkNumerique()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
// Identité publique Batik : GUID synthétique réversible dérivé du CO_PKID 55.
|
||||
Guid auteurSynthetique = MobileEntityIdentityResolver.EncodeSyntheticIdentity(
|
||||
SyncEntityTypes.Collaborateur, 55L);
|
||||
|
||||
await repository.CreateAsync("42", Command(auteurSynthetique.ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(55L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_SansAuteur_RetombeSurLeCollaborateurDuRendezVous()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command(collaborateurId: null));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(CollaborateurDuRendezVous, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurNonResolvable_RetombeSurLeCollaborateurDuRendezVous()
|
||||
{
|
||||
// GUID non canonique (ni PK numérique, ni GUID synthétique réversible) :
|
||||
// la colonne est remplie avec le repli plutôt que laissée à 0.
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command(Guid.NewGuid().ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(CollaborateurDuRendezVous, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static CompteRenduCreateCommand Command(string? collaborateurId)
|
||||
=> new()
|
||||
{
|
||||
CollaborateurId = collaborateurId,
|
||||
NoteCollaborateur = "Note du technicien"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> PlanningRow()
|
||||
=> new()
|
||||
{
|
||||
["PL_PKID"] = RendezVousId,
|
||||
["PL_IDIntervention"] = 12L,
|
||||
["PL_IDCollaborateur"] = CollaborateurDuRendezVous,
|
||||
["PL_IDGroupe"] = 0L
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> CompteRenduRow()
|
||||
=> new()
|
||||
{
|
||||
["CR_PKID"] = 1L,
|
||||
["GUID"] = Guid.NewGuid().ToString("D")
|
||||
};
|
||||
|
||||
private static ExecutedCommand Insert(FakeHfsqlConnection connection)
|
||||
=> Assert.Single(connection.Writes.Where(write =>
|
||||
write.Sql.Contains("INSERT INTO CompteRendu", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
/// <summary>Valeur du paramètre de la colonne <c>CR_IDCollaborateur</c>.</summary>
|
||||
private static object? CrIdCollaborateur(ExecutedCommand insert)
|
||||
=> HfsqlInsertParameters.ValueFor(insert.Sql, insert.Parameters, "CR_IDCollaborateur");
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
private readonly List<ExecutedCommand> _writes = [];
|
||||
|
||||
/// <summary>Écritures (ExecuteNonQuery) dans l'ordre d'exécution.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Writes => _writes;
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Planning_RDV</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PlanningRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM CompteRendu</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CompteRenduRows { get; set; } = [];
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []);
|
||||
_commands.Add(command);
|
||||
_writes.Add(command);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
{
|
||||
if (ContainsFrom(sql, "Planning_RDV"))
|
||||
return PlanningRows;
|
||||
if (ContainsFrom(sql, "CompteRendu"))
|
||||
return CompteRenduRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
using Omika.Core.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
namespace Omika.Tests.Repositories.Batik;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du repository d'identité mobile Batik.
|
||||
/// Vérifie que le chemin historique Batik continue de résoudre les permissions
|
||||
/// (UT_IDTypeProfil → AccesEtDroit.AED_IDProfil + modules ParametreMetier) et
|
||||
/// que l'utilisateur est désormais résolu par UT_PKID (et non par UT_Login).
|
||||
/// </summary>
|
||||
public class BatikMobileIdentityRepositoryTests
|
||||
{
|
||||
private const long UserIdTest = 1001;
|
||||
private const long ProfilIdTest = 3001;
|
||||
private const long ModulePkTest = 5001;
|
||||
private const string LoginTest = "TEST_USER";
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_ResoutParUT_PKID_EtRetourneALicMob()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
connection.ParametreMetierRows.Add(ModuleRow(ModulePkTest, "M.PlClb.11")); // → A.LIC.MOB
|
||||
connection.AccesEtDroitRows.Add(DroitRow(ProfilIdTest, ModulePkTest, true));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.True(permissions["A.LIC.MOB"]);
|
||||
|
||||
string utilisateurSql = connection.Queries.First(q => q.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("UT_PKID = ?", utilisateurSql);
|
||||
Assert.DoesNotContain("UT_Login = ?", utilisateurSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLoginByUserIdAsync_RetourneLoginReel()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
string? login = await repository.GetLoginByUserIdAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal(LoginTest, login);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProfileAsync_ResoutProfilBatik()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
connection.ParametreMetierRows.Add(ProfilRow(ProfilIdTest, "BATIK_PROFIL"));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
MobileProfileDto profile = await repository.GetProfileAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal("PROFIL", profile.Code);
|
||||
Assert.Equal("BATIK_PROFIL", profile.Nom);
|
||||
}
|
||||
|
||||
// ===== Fixtures =====
|
||||
|
||||
private static Dictionary<string, object?> UtilisateurRow(long pkId, string login, long profilId) => new()
|
||||
{
|
||||
["UT_PKID"] = pkId,
|
||||
["UT_Login"] = login,
|
||||
["UT_IDCollaborateur"] = 2001L,
|
||||
["UT_IDTypeProfil"] = profilId,
|
||||
["UT_AccesMobile"] = true
|
||||
};
|
||||
|
||||
// Module de la catégorie 29 (« Modules intervenir ») : PA_Valeur = clé Batik.
|
||||
private static Dictionary<string, object?> ModuleRow(long pkId, string valeur) => new()
|
||||
{
|
||||
["PA_PKID"] = pkId,
|
||||
["PA_Code"] = "29",
|
||||
["PA_Libelle"] = "Module",
|
||||
["PA_Valeur"] = valeur,
|
||||
["PA_Actif"] = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> ProfilRow(long pkId, string libelle) => new()
|
||||
{
|
||||
["PA_PKID"] = pkId,
|
||||
["PA_Code"] = "PROFIL",
|
||||
["PA_Libelle"] = libelle,
|
||||
["PA_Valeur"] = libelle,
|
||||
["PA_Actif"] = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> DroitRow(long profilId, long moduleId, bool acces) => new()
|
||||
{
|
||||
["AED_PKID"] = 4001L,
|
||||
["AED_IDProfil"] = profilId,
|
||||
["AED_IDModule"] = moduleId,
|
||||
["AED_Acces"] = acces
|
||||
};
|
||||
|
||||
// ===== Fake =====
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeHfsqlConnection(HfsqlConnectionState state) => State = state;
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public List<Dictionary<string, object?>> UtilisateurRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> ParametreMetierRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> AccesEtDroitRows { get; } = [];
|
||||
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(sql);
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> result = sql switch
|
||||
{
|
||||
_ when sql.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase) => UtilisateurRows,
|
||||
_ when sql.Contains("FROM ParametreMetier", StringComparison.OrdinalIgnoreCase) => ParametreMetierRows,
|
||||
_ when sql.Contains("FROM AccesEtDroit", StringComparison.OrdinalIgnoreCase) => AccesEtDroitRows,
|
||||
_ => []
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
namespace Omika.Tests.Repositories.Batik;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de l'annulation d'un rendez-vous Batik
|
||||
/// (<see cref="BatikRendezVousRepository.CancelAsync"/>) : aucun serveur HFSQL,
|
||||
/// aucun ODBC réel, aucun réseau. Un fake <see cref="IHfsqlConnection"/> enregistre
|
||||
/// le SQL et les paramètres et retourne des dictionnaires selon la table interrogée.
|
||||
///
|
||||
/// Objectifs :
|
||||
/// <list type="bullet">
|
||||
/// <item>un rendez-vous INDIVIDUEL (<c>PL_IDGroupe = 0</c>) n'annule que sa propre
|
||||
/// ligne <c>Planning_RDV</c> (ciblée par <c>PL_PKID</c>) ;</item>
|
||||
/// <item>un rendez-vous GROUPÉ (<c>PL_IDGroupe > 0</c>) annule TOUTES les lignes
|
||||
/// du groupe (ciblées par <c>PL_IDGroupe</c>) : tous les rendez-vous du groupe sont
|
||||
/// impactés ;</item>
|
||||
/// <item>la description reçoit la mention d'annulation
|
||||
/// <c>Note (Nom) : message</c>, le nom étant résolu côté serveur s'il n'est pas
|
||||
/// fourni.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class BatikRendezVousAnnulationTests
|
||||
{
|
||||
private const long RendezVousId = 42L;
|
||||
private const string DescriptionInitiale = "Description initiale";
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_RdvIndividuel_CibleUniquementSaPropreLigne()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)],
|
||||
ParametreMetierRows = ReferenceRows()
|
||||
};
|
||||
var repository = new BatikRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
});
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
Assert.Contains("UPDATE Planning_RDV SET", write.Sql);
|
||||
// Aucun groupe : la ligne est ciblée par sa clé primaire.
|
||||
Assert.Contains("WHERE PL_PKID = ?", write.Sql);
|
||||
Assert.DoesNotContain("PL_IDGroupe", write.Sql);
|
||||
|
||||
// Statut « Annulé » (ParametreMetier PA_PKID=1000) + origine + motif.
|
||||
Assert.Equal(1000L, write.Parameters[2]);
|
||||
Assert.Equal(2000L, write.Parameters[0]);
|
||||
Assert.Equal(3000L, write.Parameters[1]);
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
write.Parameters[3] as string);
|
||||
|
||||
// L'annulation modifie la ligne : elle porte l'estampille d'audit. Sans
|
||||
// auteur résolvable, ModifDate seul est écrit (jamais ModifUser = 0).
|
||||
Assert.Contains("ModifDate = ?", write.Sql);
|
||||
Assert.DoesNotContain("ModifUser", write.Sql);
|
||||
Assert.IsType<DateTime>(write.Parameters[4]);
|
||||
Assert.Equal(RendezVousId, write.Parameters[5]);
|
||||
AssertParametersMatchPlaceholders(write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_RdvGroupe_AnnuleToutesLesLignesDuGroupe()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
// Le rendez-vous appartient au groupe 99 : plusieurs lignes Planning_RDV
|
||||
// partagent ce PL_IDGroupe (une par collaborateur).
|
||||
PlanningRows = [PlanningRow(RendezVousId, idGroupe: 99L, collaborateurId: 7L)],
|
||||
ParametreMetierRows = ReferenceRows()
|
||||
};
|
||||
var repository = new BatikRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
});
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
// Le groupe entier est annulé : ciblage par PL_IDGroupe, jamais par PL_PKID.
|
||||
Assert.Contains("WHERE PL_IDGroupe = ?", write.Sql);
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
write.Parameters[3] as string);
|
||||
|
||||
// Estampille d'audit (ModifDate) puis la clé du groupe.
|
||||
Assert.Contains("ModifDate = ?", write.Sql);
|
||||
Assert.Equal(99L, write.Parameters[5]);
|
||||
AssertParametersMatchPlaceholders(write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_AvecAuteur_EstampilleModifDateEtModifUser()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)],
|
||||
ParametreMetierRows = ReferenceRows()
|
||||
};
|
||||
var repository = new BatikRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync(
|
||||
"42",
|
||||
new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
},
|
||||
auteur: new AuditActor("77", null));
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
Assert.Contains("ModifDate = ?", write.Sql);
|
||||
Assert.Contains("ModifUser = ?", write.Sql);
|
||||
Assert.IsType<DateTime>(write.Parameters[4]);
|
||||
Assert.Equal(77L, write.Parameters[5]);
|
||||
Assert.Equal(RendezVousId, write.Parameters[6]);
|
||||
AssertParametersMatchPlaceholders(write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SansNomCollaborateur_ResoutLeNomDepuisLeCollaborateurAffecte()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)],
|
||||
ParametreMetierRows = ReferenceRows(),
|
||||
CollaborateurRows =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["CO_PKID"] = 7L,
|
||||
["CO_Nom"] = "DUPONT",
|
||||
["CO_Prenom"] = "Jean"
|
||||
}
|
||||
]
|
||||
};
|
||||
var repository = new BatikRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent"
|
||||
});
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
write.Parameters[3] as string);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie que le nombre de paramètres positionnels correspond EXACTEMENT au
|
||||
/// nombre de <c>?</c> du SQL : une estampille d'audit oubliée (ou en trop)
|
||||
/// décale les paramètres et corrompt silencieusement l'écriture.
|
||||
/// </summary>
|
||||
private static void AssertParametersMatchPlaceholders(ExecutedCommand command)
|
||||
{
|
||||
int placeholders = command.Sql.Count(c => c == '?');
|
||||
Assert.Equal(placeholders, command.Parameters.Count);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PlanningRow(long pkid, long idGroupe, long collaborateurId)
|
||||
=> new()
|
||||
{
|
||||
["PL_PKID"] = pkid,
|
||||
["PL_Description"] = DescriptionInitiale,
|
||||
["PL_IDGroupe"] = idGroupe,
|
||||
["PL_IDCollaborateur"] = collaborateurId
|
||||
};
|
||||
|
||||
/// <summary>Référentiel minimal : origine, motif et statut « Annulé ».</summary>
|
||||
private static IReadOnlyList<IReadOnlyDictionary<string, object?>> ReferenceRows() =>
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PA_PKID"] = 2000L,
|
||||
["PA_Code"] = "O1",
|
||||
["PA_Libelle"] = "Externe : Client"
|
||||
},
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PA_PKID"] = 3000L,
|
||||
["PA_Code"] = "M1",
|
||||
["PA_Libelle"] = "Absent"
|
||||
},
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PA_PKID"] = 1000L,
|
||||
["PA_Code"] = "15",
|
||||
["PA_Valeur"] = "S.RDV.ANNU",
|
||||
["PA_Libelle"] = "Annulé"
|
||||
}
|
||||
];
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
private readonly List<ExecutedCommand> _writes = [];
|
||||
|
||||
/// <summary>Écritures (ExecuteNonQuery) dans l'ordre d'exécution.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Writes => _writes;
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Planning_RDV</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PlanningRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM ParametreMetier</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ParametreMetierRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Collaborateur</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []);
|
||||
_commands.Add(command);
|
||||
_writes.Add(command);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
{
|
||||
if (ContainsFrom(sql, "Planning_RDV"))
|
||||
return PlanningRows;
|
||||
if (ContainsFrom(sql, "ParametreMetier"))
|
||||
return ParametreMetierRows;
|
||||
if (ContainsFrom(sql, "Collaborateur"))
|
||||
return CollaborateurRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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,187 @@
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Planning;
|
||||
using Omika.Data.Configuration;
|
||||
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Models.Batik;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests ciblés de la quarantaine read-only des rendez-vous Batik dont la
|
||||
/// plage de dates est physiquement invalide (PL_DateFin < PL_DateDebut).
|
||||
///
|
||||
/// Politique : une telle ligne n'est PAS transformée en objet domaine
|
||||
/// (DateTimeRange resterait strict). Elle est détectée, comptée, signalée et
|
||||
/// exclue du résultat métier, sans modifier les données HFSQL.
|
||||
/// </summary>
|
||||
public class BatikRendezVousQuarantineTests
|
||||
{
|
||||
// =========================================================================
|
||||
// TEST 1 — ligne normale
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ValidRow_IsMappedAndReturned()
|
||||
{
|
||||
var connection = new QuarantineHfsqlConnection();
|
||||
connection.RendezVousRows =
|
||||
[
|
||||
RendezVousDict(1, "Réunion", new DateTime(2026, 1, 15, 9, 0, 0), new DateTime(2026, 1, 15, 10, 0, 0))
|
||||
];
|
||||
|
||||
var repo = new BatikRendezVousRepository(connection);
|
||||
|
||||
PagedResult<RendezVous> result = await repo.SearchAsync(new RendezVousSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal("Réunion", result.Items[0].Titre);
|
||||
Assert.Equal(new DateTime(2026, 1, 15, 9, 0, 0), result.Items[0].Periode.Start);
|
||||
Assert.Equal(new DateTime(2026, 1, 15, 10, 0, 0), result.Items[0].Periode.End);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TEST 2 — ligne invalide
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_InvalidRow_IsExcludedWithoutException()
|
||||
{
|
||||
var connection = new QuarantineHfsqlConnection();
|
||||
connection.RendezVousRows =
|
||||
[
|
||||
RendezVousDict(281474976710664, "Anomalie", new DateTime(2022, 10, 15, 7, 30, 0), new DateTime(2022, 10, 14, 11, 30, 0))
|
||||
];
|
||||
|
||||
var repo = new BatikRendezVousRepository(connection);
|
||||
|
||||
// Aucune DomainValidationException ne doit être levée.
|
||||
PagedResult<RendezVous> result = await repo.SearchAsync(new RendezVousSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Items);
|
||||
Assert.Equal(0, result.TotalCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TEST 3 — mélange : 3 valides + 2 invalides
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_MixedRows_ReturnsOnlyValidOnes()
|
||||
{
|
||||
var connection = new QuarantineHfsqlConnection();
|
||||
connection.RendezVousRows =
|
||||
[
|
||||
RendezVousDict(1, "Valide 1", new DateTime(2026, 1, 15, 9, 0, 0), new DateTime(2026, 1, 15, 10, 0, 0)),
|
||||
RendezVousDict(2, "Invalide 1", new DateTime(2022, 10, 15, 7, 30, 0), new DateTime(2022, 10, 14, 11, 30, 0)),
|
||||
RendezVousDict(3, "Valide 2", new DateTime(2026, 1, 16, 9, 0, 0), new DateTime(2026, 1, 16, 10, 0, 0)),
|
||||
RendezVousDict(4, "Invalide 2", new DateTime(2022, 11, 15, 7, 30, 0), new DateTime(2022, 11, 14, 11, 30, 0)),
|
||||
RendezVousDict(5, "Valide 3", new DateTime(2026, 1, 17, 9, 0, 0), new DateTime(2026, 1, 17, 10, 0, 0))
|
||||
];
|
||||
|
||||
var repo = new BatikRendezVousRepository(connection);
|
||||
|
||||
PagedResult<RendezVous> result = await repo.SearchAsync(new RendezVousSearchCriteria());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Items.Count);
|
||||
Assert.Equal(3, result.TotalCount);
|
||||
Assert.DoesNotContain(result.Items, r => r.Titre.StartsWith("Invalide", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static Dictionary<string, object?> RendezVousDict(
|
||||
long pkid, string? objet, DateTime debut, DateTime fin)
|
||||
=> new()
|
||||
{
|
||||
["PL_PKID"] = pkid,
|
||||
["PL_Objet"] = objet,
|
||||
["PL_DateDebut"] = debut,
|
||||
["PL_DateFin"] = fin,
|
||||
["PL_IDTypeActivite"] = 0L,
|
||||
["PL_IDStatut"] = 0L,
|
||||
["PL_IDCollaborateur"] = 0L,
|
||||
["PL_IDOrigineAnnul"] = 0UL,
|
||||
["PL_IDMotifAnnul"] = 0UL,
|
||||
["PL_Prive"] = false,
|
||||
["PL_Superposable"] = false,
|
||||
["Archive"] = false
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// QuarantineHfsqlConnection
|
||||
// =========================================================================
|
||||
|
||||
private sealed class QuarantineHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> RendezVousRows { get; set; } = [];
|
||||
|
||||
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)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ContainsFrom(sql, "Planning_RDV"))
|
||||
return Task.FromResult(RendezVousRows);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
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,51 @@
|
||||
using System.Reflection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.HfSql.Models.Batik;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Test miroir Batik (non-régression) : vérifie que les colonnes référencées
|
||||
/// par les SELECT de <see cref="BatikRendezVousRepository"/> existent dans les
|
||||
/// modèles physiques Batik. Miroir du garde-fou Omika.
|
||||
/// </summary>
|
||||
public class BatikRendezVousSqlConsistencyTests
|
||||
{
|
||||
public static IEnumerable<object[]> Cases()
|
||||
{
|
||||
yield return [typeof(BatikRendezVousRepository), "SqlSelectRendezVousColumns", typeof(PlanningRDVRow)];
|
||||
yield return [typeof(BatikRendezVousRepository), "SqlSelectPersonneColumns", typeof(PlanningRDVPersonneRow)];
|
||||
yield return [typeof(BatikRendezVousRepository), "SqlSelectParametreMetierColumns", typeof(ParametreMetierRow)];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void ColonnesSelect_ExistentDansLeModelePhysique(Type repositoryType, string fieldName, Type rowType)
|
||||
{
|
||||
FieldInfo? field = repositoryType.GetField(
|
||||
fieldName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
|
||||
Assert.True(field is not null, $"Champ « {fieldName} » introuvable dans {repositoryType.Name}.");
|
||||
|
||||
string sql = (string)field!.GetRawConstantValue()!;
|
||||
var physicalColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (PropertyInfo property in rowType.GetProperties())
|
||||
{
|
||||
HfSqlColumnAttribute? attr = property.GetCustomAttribute<HfSqlColumnAttribute>();
|
||||
if (attr is not null)
|
||||
{
|
||||
physicalColumns.Add(attr.ColumnName);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotEmpty(physicalColumns);
|
||||
|
||||
foreach (string column in sql.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
Assert.True(
|
||||
physicalColumns.Contains(column),
|
||||
$"{repositoryType.Name}.{fieldName} référence la colonne « {column} » absente du modèle {rowType.Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Data.Common;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie que l'INSERT dans <c>Client_Adresse</c> n'écrit AUCUNE colonne d'audit
|
||||
/// (CreationDate/User, ModifDate/User) — absentes du schéma canonique Batik et
|
||||
/// Omika — et que le mapping des colonnes métier est correct dans chaque schéma.
|
||||
/// </summary>
|
||||
public class ClientAdresseInsertTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Omika_ClientAdresse_Insert_SansColonneAudit()
|
||||
{
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
var repository = new OmikaClientRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
repository.CreateAsync(CreateCommandWithAddress()));
|
||||
|
||||
var insert = connection.Commands.Single(c => c.Sql.StartsWith("INSERT INTO Client_Adresse"));
|
||||
Assert.DoesNotContain("CreationDate", insert.Sql);
|
||||
Assert.DoesNotContain("CreationUser", insert.Sql);
|
||||
Assert.DoesNotContain("ModifDate", insert.Sql);
|
||||
Assert.DoesNotContain("ModifUser", insert.Sql);
|
||||
Assert.Contains("CA_IDAdresse", insert.Sql);
|
||||
Assert.Contains("CA_IDClient", insert.Sql);
|
||||
Assert.Contains("CA_IDTypeAdresse", insert.Sql);
|
||||
Assert.Contains("CA_Principale", insert.Sql);
|
||||
// Clé composée HFSQL auto-calculée : jamais écrite explicitement en Omika.
|
||||
Assert.DoesNotContain("CA_IDCli_IDAd", insert.Sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Omika_ClientAdresse_ParametresCorrects()
|
||||
{
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
var repository = new OmikaClientRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
repository.CreateAsync(CreateCommandWithAddress()));
|
||||
|
||||
var insert = connection.Commands.Single(c => c.Sql.StartsWith("INSERT INTO Client_Adresse"));
|
||||
object?[] p = insert.Parameters;
|
||||
Assert.Equal(4, p.Length);
|
||||
Assert.Equal(1L, p[0]); // CA_IDAdresse (identité auto-générée = 1)
|
||||
Assert.Equal(1L, p[1]); // CA_IDClient (identité auto-générée = 1)
|
||||
Assert.Equal(0, p[2]); // CA_IDTypeAdresse
|
||||
Assert.Equal(true, p[3]); // CA_Principale
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batik_ClientAdresse_Insert_SansColonneAudit()
|
||||
{
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
var repository = new BatikClientRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
repository.CreateAsync(CreateCommandWithAddress()));
|
||||
|
||||
var insert = connection.Commands.Single(c => c.Sql.StartsWith("INSERT INTO Client_Adresse"));
|
||||
Assert.DoesNotContain("CreationDate", insert.Sql);
|
||||
Assert.DoesNotContain("CreationUser", insert.Sql);
|
||||
Assert.DoesNotContain("ModifDate", insert.Sql);
|
||||
Assert.DoesNotContain("ModifUser", insert.Sql);
|
||||
Assert.Contains("CA_IDAdresse", insert.Sql);
|
||||
Assert.Contains("CA_IDClient", insert.Sql);
|
||||
Assert.Contains("CA_IDTypeAdresse", insert.Sql);
|
||||
Assert.Contains("CA_Principale", insert.Sql);
|
||||
// Batik écrit la clé composée explicitement (comportement historique).
|
||||
Assert.Contains("CA_IDCli_IDAd", insert.Sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batik_ClientAdresse_ParametresCorrects()
|
||||
{
|
||||
var connection = new SpyingHfsqlConnection();
|
||||
var repository = new BatikClientRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
repository.CreateAsync(CreateCommandWithAddress()));
|
||||
|
||||
var insert = connection.Commands.Single(c => c.Sql.StartsWith("INSERT INTO Client_Adresse"));
|
||||
object?[] p = insert.Parameters;
|
||||
Assert.Equal(5, p.Length);
|
||||
Assert.Equal(1L, p[0]); // CA_IDAdresse
|
||||
Assert.Equal(1L, p[1]); // CA_IDClient
|
||||
Assert.Equal("11", p[2]); // CA_IDCli_IDAd = clientId + adresseId
|
||||
Assert.Equal(0, p[3]); // CA_IDTypeAdresse
|
||||
Assert.Equal(true, p[4]); // CA_Principale
|
||||
}
|
||||
|
||||
private static ClientCreateCommand CreateCommandWithAddress() => new()
|
||||
{
|
||||
Nom = "Test",
|
||||
Code = "T-001",
|
||||
AdresseLigne1 = "1 rue du Test",
|
||||
AdresseCodePostal = "75000",
|
||||
AdresseVille = "Paris"
|
||||
};
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, object?[] Parameters);
|
||||
|
||||
private sealed class SpyingHfsqlConnection : IHfsqlConnection, IHfsqlWriteConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
public DbTransaction? ActiveTransaction => null;
|
||||
|
||||
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
private void Record(string sql, IReadOnlyList<object?>? parameters)
|
||||
=> _commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? Array.Empty<object?>()));
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(sql, parameters);
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task RollbackAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<long?>> ExecuteInTransactionAsync(IReadOnlyList<HfsqlWriteOperation> operations, CancellationToken cancellationToken = default, DbTransaction? transaction = null)
|
||||
{
|
||||
foreach (HfsqlWriteOperation operation in operations)
|
||||
{
|
||||
Record(operation.Sql, operation.Parameters);
|
||||
}
|
||||
|
||||
// Une identité auto-générée (1) pour chaque opération.
|
||||
return Task.FromResult<IReadOnlyList<long?>>(operations.Select(_ => (long?)1L).ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Audit;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du résolveur central d'audit utilisateur (HfSqlAuditUserResolver) :
|
||||
/// priorité UserId (UT_PKID direct, sans requête), fallback CollaborateurId
|
||||
/// (numérique → UT_PKID, GUID Omika, GUID synthétique Batik), et garantie que
|
||||
/// l'on ne confond JAMAIS utilisateur et collaborateur.
|
||||
/// </summary>
|
||||
public class HfSqlAuditUserResolverTests
|
||||
{
|
||||
private sealed class FakeConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeConnection()
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
}
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
public List<string> Queries { get; } = [];
|
||||
public List<Dictionary<string, object?>> UtilisateurRows { get; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(sql);
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>(
|
||||
sql.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase)
|
||||
? UtilisateurRows.Cast<IReadOnlyDictionary<string, object?>>().ToList()
|
||||
: []);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
|
||||
private static AuditActor Actor(string? userId, string? collaborateurId) => new(userId, collaborateurId);
|
||||
|
||||
[Fact]
|
||||
public async Task UserIdPrioritaire_RetourneUtPk_SansRequete()
|
||||
{
|
||||
// Arrange : UserId = 57 (UT_PKID), CollaborateurId = 123 (CO_PKID différent).
|
||||
var connection = new FakeConnection();
|
||||
|
||||
// Act
|
||||
long result = await HfSqlAuditUserResolver.ResolveAsync(
|
||||
connection, DatabaseSchemaVersion.Omika, Actor("57", "123"));
|
||||
|
||||
// Assert : 57 (jamais 123), et AUCUNE requête HFSQL.
|
||||
Assert.Equal(57, result);
|
||||
Assert.Empty(connection.Queries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserIdValide_NeRequiertAucuneRequete()
|
||||
{
|
||||
var connection = new FakeConnection();
|
||||
long result = await HfSqlAuditUserResolver.ResolveAsync(
|
||||
connection, DatabaseSchemaVersion.Batik, Actor("42", null));
|
||||
Assert.Equal(42, result);
|
||||
Assert.Empty(connection.Queries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserIdAbsent_CollaborateurNumerique_ResoutUtPk()
|
||||
{
|
||||
// Arrange : UserId vide, CollaborateurId numérique = CO_PKID 123.
|
||||
var connection = new FakeConnection();
|
||||
connection.UtilisateurRows.Add(new Dictionary<string, object?> { ["UT_PKID"] = 77L });
|
||||
|
||||
// Act
|
||||
long result = await HfSqlAuditUserResolver.ResolveAsync(
|
||||
connection, DatabaseSchemaVersion.Omika, Actor(null, "123"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(77, result);
|
||||
Assert.Single(connection.Queries);
|
||||
Assert.Contains("UT_IDCollaborateur", connection.Queries[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyGuidOmika_ResoutUtPk()
|
||||
{
|
||||
// Arrange : CollaborateurId = GUID physique Omika.
|
||||
var connection = new FakeConnection();
|
||||
connection.UtilisateurRows.Add(new Dictionary<string, object?> { ["UT_PKID"] = 88L });
|
||||
|
||||
// Act
|
||||
long result = await HfSqlAuditUserResolver.ResolveAsync(
|
||||
connection, DatabaseSchemaVersion.Omika, Actor(null, Guid.NewGuid().ToString("D")));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(88, result);
|
||||
Assert.Single(connection.Queries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyGuidBatik_DecodePuisResoutUtPk()
|
||||
{
|
||||
// Arrange : GUID synthétique Batik (marqueur 0x4B, version 0x01, type 0x08,
|
||||
// PK 123 sur les octets 3..10 big-endian).
|
||||
byte[] bytes = new byte[16];
|
||||
bytes[0] = 0x4B;
|
||||
bytes[1] = 0x01;
|
||||
bytes[2] = 0x08;
|
||||
bytes[10] = 0x7B; // 123
|
||||
var batikGuid = new Guid(bytes);
|
||||
|
||||
var connection = new FakeConnection();
|
||||
connection.UtilisateurRows.Add(new Dictionary<string, object?> { ["UT_PKID"] = 99L });
|
||||
|
||||
// Act
|
||||
long result = await HfSqlAuditUserResolver.ResolveAsync(
|
||||
connection, DatabaseSchemaVersion.Batik, Actor(null, batikGuid.ToString("D")));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(99, result);
|
||||
Assert.Single(connection.Queries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Lecture des paramètres positionnels d'un <c>INSERT</c> capturé par un fake
|
||||
/// <c>IHfsqlConnection</c> : les <c>?</c> suivent l'ordre de la liste de colonnes,
|
||||
/// ce qui permet d'assertir une colonne précise sans dépendre de son rang.
|
||||
/// </summary>
|
||||
internal static class HfsqlInsertParameters
|
||||
{
|
||||
/// <summary>
|
||||
/// Retourne la valeur du paramètre correspondant à <paramref name="column"/>
|
||||
/// dans l'<c>INSERT</c> décrit par <paramref name="sql"/>.
|
||||
/// </summary>
|
||||
public static object? ValueFor(string sql, IReadOnlyList<object?> parameters, string column)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sql);
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
|
||||
int open = sql.IndexOf('(');
|
||||
int close = open < 0 ? -1 : sql.IndexOf(')', open);
|
||||
|
||||
if (open < 0 || close <= open)
|
||||
{
|
||||
throw new InvalidOperationException($"Instruction sans liste de colonnes : {sql}");
|
||||
}
|
||||
|
||||
string[] columns = sql[(open + 1)..close]
|
||||
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
int index = Array.FindIndex(
|
||||
columns,
|
||||
name => string.Equals(name, column, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Colonne « {column} » absente de l'instruction : {sql}");
|
||||
}
|
||||
|
||||
return parameters[index];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Technical;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du repository de licence pour la résolution multi-client de l'URL API
|
||||
/// (<c>ClientBase.CL_IDWebServiceMobile</c> → <c>ServeurWebService.WS_AdresseAWP</c>).
|
||||
/// </summary>
|
||||
public class LicenceRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetMobileApiUrlForClientAsync_JointureParametree_RetourneAdresse()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?> { ["WS_PKID"] = 10L, ["WS_AdresseAWP"] = "https://api-a.example.test" }
|
||||
];
|
||||
var repository = new LicenceRepository(connection);
|
||||
|
||||
string? url = await repository.GetMobileApiUrlForClientAsync("CLIENT.A");
|
||||
|
||||
Assert.Equal("https://api-a.example.test", url);
|
||||
Assert.Contains("JOIN ServeurWebService", connection.LastSql);
|
||||
Assert.Contains("ClientBase.CL_Code = ?", connection.LastSql);
|
||||
Assert.DoesNotContain("CLIENT.A", connection.LastSql); // CodeClient non concaténé
|
||||
Assert.NotNull(connection.LastParameters);
|
||||
Assert.Single(connection.LastParameters!);
|
||||
Assert.Equal("CLIENT.A", connection.LastParameters![0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMobileApiUrlForClientAsync_AucuneLigne_RetourneNull()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.QueryResult = [];
|
||||
var repository = new LicenceRepository(connection);
|
||||
|
||||
Assert.Null(await repository.GetMobileApiUrlForClientAsync("CLIENT.INCONNU"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMobileApiUrlForClientAsync_CodeVide_RetourneNullSansRequete()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
var repository = new LicenceRepository(connection);
|
||||
|
||||
Assert.Null(await repository.GetMobileApiUrlForClientAsync(" "));
|
||||
Assert.Equal(0, connection.QueryCount);
|
||||
}
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeHfsqlConnection(HfsqlConnectionState state) => State = state;
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public int QueryCount { get; private set; }
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> QueryResult { get; set; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueryCount++;
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
return Task.FromResult(QueryResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de non-régression du <see cref="OmikaCollaborateurRepository"/> :
|
||||
/// la lecture par identifiant (<see cref="OmikaCollaborateurRepository.GetDetailsAsync"/>)
|
||||
/// doit couvrir les TROIS formes de stockage physique du GUID réellement
|
||||
/// observées en base (D, N, et « D tronquée » à 32 caractères écrite par WinDev)
|
||||
/// via <see cref="PhysicalGuidParser.GetStorageCandidates"/>, et non un
|
||||
/// <c>GUID = 'D'</c> unique qui ne matcherait jamais la forme tronquée.
|
||||
/// </summary>
|
||||
public class OmikaCollaborateurRepositoryTests
|
||||
{
|
||||
private const string PublicGuidD = "62224eca-4565-4f47-9197-5c7439400000";
|
||||
|
||||
[Theory]
|
||||
[InlineData(PublicGuidD)] // forme D (36 car.)
|
||||
[InlineData("62224eca45654f4791975c7439400000")] // forme N (32 hex)
|
||||
[InlineData("62224eca-4565-4f47-9197-5c743940")] // forme D tronquée (32 car.)
|
||||
public async Task GetDetailsAsync_GuidPublic_RetrouveLaFormePhysiqueStockee(string storedGuid)
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = storedGuid,
|
||||
CollaborateurRowDict = CollaborateurRow(281474976710675L, storedGuid, "Dupont", "Jean")
|
||||
};
|
||||
var repository = new OmikaCollaborateurRepository(connection);
|
||||
|
||||
var collaborateur = await repository.GetDetailsAsync(PublicGuidD);
|
||||
|
||||
Assert.NotNull(collaborateur);
|
||||
Assert.Equal(281474976710675L, collaborateur!.SourceReference!.NumericId!.Value);
|
||||
|
||||
// La lecture doit interroger les TROIS formes (D, N, tronquée), pas
|
||||
// seulement « D ».
|
||||
string sql = connection.Commands
|
||||
.Select(c => c.Sql)
|
||||
.First(s => s.Contains("FROM Collaborateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("GUID IN (", sql);
|
||||
Assert.Contains("?, ?, ?", sql);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CollaborateurRow(long pkid, string guid, string nom, string prenom)
|
||||
=> new()
|
||||
{
|
||||
["CO_PKID"] = pkid,
|
||||
["GUID"] = guid,
|
||||
["CO_Nom"] = nom,
|
||||
["CO_Prenom"] = prenom
|
||||
};
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
public string? StoredGuid { get; set; }
|
||||
public IReadOnlyDictionary<string, object?> CollaborateurRowDict { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql, parameters));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
|
||||
string sql, IReadOnlyList<object?>? parameters)
|
||||
{
|
||||
if (ContainsFrom(sql, "Collaborateur"))
|
||||
{
|
||||
// Retourne la ligne si la forme stockée fait partie des candidats.
|
||||
return StoredGuid is not null
|
||||
&& parameters is not null
|
||||
&& parameters.Any(p => string.Equals(p as string, StoredGuid, StringComparison.Ordinal))
|
||||
? [CollaborateurRowDict]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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,292 @@
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Services.Mobile;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de la création d'un compte rendu Omika
|
||||
/// (<see cref="OmikaCompteRenduRepository.CreateAsync"/>) centrés sur la colonne
|
||||
/// <c>CR_IDCollaborateur</c> : aucun serveur HFSQL, aucun ODBC réel, aucun réseau.
|
||||
/// Un fake <see cref="IHfsqlConnection"/> enregistre le SQL et les paramètres, et
|
||||
/// retourne des dictionnaires selon la table interrogée.
|
||||
///
|
||||
/// Objectifs :
|
||||
/// <list type="bullet">
|
||||
/// <item>l'auteur du compte rendu (identité publique du collaborateur connecté)
|
||||
/// est TOUJOURS écrit dans <c>CR_IDCollaborateur</c> — la valeur était
|
||||
/// précédemment codée en dur à <c>0</c> ;</item>
|
||||
/// <item>les trois formes d'identité publique sont résolues : PK numérique
|
||||
/// (production mobile), GUID physique Omika, GUID synthétique Batik ;</item>
|
||||
/// <item>à défaut d'auteur résolvable, le collaborateur unique du rendez-vous
|
||||
/// individuel sert de repli, et un rendez-vous groupé n'écrit aucun collaborateur
|
||||
/// arbitraire.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class OmikaCompteRenduRepositoryTests
|
||||
{
|
||||
private const long RendezVousPkid = 42L;
|
||||
private static readonly Guid RendezVousGuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid AuteurGuid = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly Guid AffectationGuid = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurNumerique_RenseigneCrIdCollaborateur()
|
||||
{
|
||||
// Le jeton mobile porte le PK numérique du collaborateur (CO_PKID) :
|
||||
// aucune requête de résolution n'est nécessaire.
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new OmikaCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync(RendezVousGuid.ToString("D"), Command("7"));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(7L, CrIdCollaborateur(insert));
|
||||
Assert.DoesNotContain(
|
||||
connection.Queries,
|
||||
query => query.Sql.Contains("FROM Collaborateur", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurGuidPhysique_ResoutLePkDepuisLeCollaborateur()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()],
|
||||
CollaborateurRows = [new Dictionary<string, object?> { ["CO_PKID"] = 33L }]
|
||||
};
|
||||
var repository = new OmikaCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync(RendezVousGuid.ToString("D"), Command(AuteurGuid.ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(33L, CrIdCollaborateur(insert));
|
||||
|
||||
// Le GUID est cherché sous toutes ses formes de stockage canoniques
|
||||
// (le GUID physique Omika peut être tronqué à 32 caractères).
|
||||
ExecutedCommand resolution = Assert.Single(connection.Queries.Where(query =>
|
||||
query.Sql.Contains("FROM Collaborateur", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("GUID IN (", resolution.Sql);
|
||||
Assert.Contains(
|
||||
resolution.Parameters,
|
||||
value => string.Equals(value as string, AuteurGuid.ToString("D"), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurGuidSynthetiqueBatik_DecodeLePkNumerique()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new OmikaCompteRenduRepository(connection);
|
||||
|
||||
// Identité publique Batik : GUID synthétique réversible dérivé du CO_PKID 55.
|
||||
Guid auteurSynthetique = MobileEntityIdentityResolver.EncodeSyntheticIdentity(
|
||||
SyncEntityTypes.Collaborateur, 55L);
|
||||
|
||||
await repository.CreateAsync(RendezVousGuid.ToString("D"), Command(auteurSynthetique.ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(55L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_SansAuteur_RemplitAvecLeCollaborateurUniqueDuRendezVous()
|
||||
{
|
||||
// Aucun auteur transmis : le rendez-vous individuel (une seule
|
||||
// affectation) fournit le collaborateur, plutôt que d'écrire 0.
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()],
|
||||
AffectationRows = [new Dictionary<string, object?> { ["RVC_GUID_Colb"] = AffectationGuid.ToString("D") }],
|
||||
CollaborateurRows = [new Dictionary<string, object?> { ["CO_PKID"] = 77L }]
|
||||
};
|
||||
var repository = new OmikaCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync(RendezVousGuid.ToString("D"), Command(collaborateurId: null));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(77L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GroupeSansAuteur_NAttribueAucunCollaborateurArbitraire()
|
||||
{
|
||||
// Rendez-vous groupé : plusieurs affectations. Aucun collaborateur n'est
|
||||
// privilégié, la colonne reste à 0 (jamais un collaborateur arbitraire).
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()],
|
||||
AffectationRows =
|
||||
[
|
||||
new Dictionary<string, object?> { ["RVC_GUID_Colb"] = AffectationGuid.ToString("D") },
|
||||
new Dictionary<string, object?> { ["RVC_GUID_Colb"] = AuteurGuid.ToString("D") }
|
||||
],
|
||||
CollaborateurRows = [new Dictionary<string, object?> { ["CO_PKID"] = 77L }]
|
||||
};
|
||||
var repository = new OmikaCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync(RendezVousGuid.ToString("D"), Command(collaborateurId: null));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(0L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static CompteRenduCreateCommand Command(string? collaborateurId)
|
||||
=> new()
|
||||
{
|
||||
CollaborateurId = collaborateurId,
|
||||
NoteCollaborateur = "Note du technicien"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> PlanningRow()
|
||||
=> new()
|
||||
{
|
||||
["RV_PKID"] = RendezVousPkid,
|
||||
["RV_IdInter"] = 12L
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> CompteRenduRow()
|
||||
=> new()
|
||||
{
|
||||
["CR_PKID"] = 1L,
|
||||
["GUID"] = Guid.NewGuid().ToString("D")
|
||||
};
|
||||
|
||||
private static ExecutedCommand Insert(FakeHfsqlConnection connection)
|
||||
=> Assert.Single(connection.Writes.Where(write =>
|
||||
write.Sql.Contains("INSERT INTO CompteRendu", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
/// <summary>Valeur du paramètre de la colonne <c>CR_IDCollaborateur</c>.</summary>
|
||||
private static object? CrIdCollaborateur(ExecutedCommand insert)
|
||||
=> HfsqlInsertParameters.ValueFor(insert.Sql, insert.Parameters, "CR_IDCollaborateur");
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
private readonly List<ExecutedCommand> _writes = [];
|
||||
|
||||
/// <summary>Écritures (ExecuteNonQuery) dans l'ordre d'exécution.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Writes => _writes;
|
||||
|
||||
/// <summary>Requêtes de lecture (QueryAsync) dans l'ordre d'exécution.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Queries => _commands;
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM RDV_Planning</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PlanningRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM RDV_Collaborateur</c> (affectations).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> AffectationRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Collaborateur</c> (annuaire).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM CompteRendu</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CompteRenduRows { get; set; } = [];
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []);
|
||||
_commands.Add(command);
|
||||
_writes.Add(command);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
{
|
||||
if (ContainsFrom(sql, "RDV_Planning"))
|
||||
return PlanningRows;
|
||||
if (ContainsFrom(sql, "RDV_Collaborateur"))
|
||||
return AffectationRows;
|
||||
if (ContainsFrom(sql, "CompteRendu"))
|
||||
return CompteRenduRows;
|
||||
if (ContainsFrom(sql, "Collaborateur"))
|
||||
return CollaborateurRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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,140 @@
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de non-régression du <see cref="OmikaContactRepository"/> : la lecture
|
||||
/// par identifiant (<see cref="OmikaContactRepository.GetDetailsAsync"/>) doit
|
||||
/// couvrir les TROIS formes de stockage physique du GUID (D, N, « D tronquée »
|
||||
/// à 32 caractères écrite par WinDev), pas seulement « D ».
|
||||
/// </summary>
|
||||
public class OmikaContactRepositoryTests
|
||||
{
|
||||
private const string PublicGuidD = "0b0cfe4f-5bd8-4a69-8c67-802c79bc0000";
|
||||
|
||||
[Theory]
|
||||
[InlineData(PublicGuidD)] // forme D (36 car.)
|
||||
[InlineData("0b0cfe4f5bd84a698c67802c79bc0000")] // forme N (32 hex)
|
||||
[InlineData("0b0cfe4f-5bd8-4a69-8c67-802c79bc")] // forme D tronquée (32 car.)
|
||||
public async Task GetDetailsAsync_GuidPublic_RetrouveLaFormePhysiqueStockee(string storedGuid)
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = storedGuid,
|
||||
PersonneRowDict = PersonneRow(42L, storedGuid, "Dupont", "Jean")
|
||||
};
|
||||
var repository = new OmikaContactRepository(connection);
|
||||
|
||||
var contact = await repository.GetDetailsAsync(PublicGuidD);
|
||||
|
||||
Assert.NotNull(contact);
|
||||
Assert.Equal(42L, contact!.SourceReference!.NumericId!.Value);
|
||||
|
||||
string sql = connection.Commands
|
||||
.Select(c => c.Sql)
|
||||
.First(s => s.Contains("FROM Personne", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("GUID IN (", sql);
|
||||
Assert.Contains("?, ?, ?", sql);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PersonneRow(long pkid, string guid, string nom, string prenom)
|
||||
=> new()
|
||||
{
|
||||
["PE_PKID"] = pkid,
|
||||
["GUID"] = guid,
|
||||
["PE_Nom"] = nom,
|
||||
["PE_Prenom"] = prenom
|
||||
};
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
public string? StoredGuid { get; set; }
|
||||
public IReadOnlyDictionary<string, object?> PersonneRowDict { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql, parameters));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
|
||||
string sql, IReadOnlyList<object?>? parameters)
|
||||
{
|
||||
if (ContainsFrom(sql, "Personne"))
|
||||
{
|
||||
return StoredGuid is not null
|
||||
&& parameters is not null
|
||||
&& parameters.Any(p => string.Equals(p as string, StoredGuid, StringComparison.Ordinal))
|
||||
? [PersonneRowDict]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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,148 @@
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de non-régression du <see cref="OmikaDossierRepository"/> : le filtre
|
||||
/// « dossiers d'un client » (<see cref="DossierSearchCriteria.ClientId"/>) doit
|
||||
/// résoudre le GUID public du client vers sa forme physique TRONQUÉE (32
|
||||
/// caractères, WinDev) via <see cref="PhysicalGuidParser.GetStorageCandidates"/>.
|
||||
/// Un <c>Client WHERE GUID = 'N'</c> ne matcherait pas cette forme et aucun
|
||||
/// dossier ne serait remonté.
|
||||
/// </summary>
|
||||
public class OmikaDossierRepositoryTests
|
||||
{
|
||||
private const string ClientPublicGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc0000";
|
||||
private const string ClientTruncatedGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc";
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ClientGuidTronque_RetrouveLesDossiersDuClient()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
DossierSearchRowDict = DossierSearchRow(100L, 7L, ClientTruncatedGuid, "Client Test")
|
||||
};
|
||||
var repository = new OmikaDossierRepository(connection);
|
||||
|
||||
var result = await repository.SearchAsync(new DossierSearchCriteria
|
||||
{
|
||||
ClientId = ClientPublicGuid
|
||||
});
|
||||
|
||||
Assert.NotEmpty(result.Items);
|
||||
Assert.Equal(7L, result.Items[0].ClientSourceReference!.NumericId!.Value);
|
||||
|
||||
// Le filtre client doit résoudre le GUID par une sous-requête IN couvrant
|
||||
// les TROIS formes (D, N, tronquée), pas un « GUID = 'N' » unique.
|
||||
string sql = connection.Commands
|
||||
.Select(c => c.Sql)
|
||||
.First(s => s.Contains("FROM Dossier", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("SELECT CL_PKID FROM Client WHERE GUID IN (", sql);
|
||||
Assert.Contains("?, ?, ?", sql);
|
||||
|
||||
// La forme tronquée doit effectivement figurer parmi les paramètres.
|
||||
ExecutedCommand command = connection.Commands
|
||||
.First(c => c.Sql.Contains("FROM Dossier", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(command.Parameters, p => string.Equals(p as string, ClientTruncatedGuid, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> DossierSearchRow(long pkid, long idClient, string clientGuid, string clientNom)
|
||||
=> new()
|
||||
{
|
||||
["DO_PKID"] = pkid,
|
||||
["DO_IDClient"] = idClient,
|
||||
["DO_Titre"] = "Dossier test",
|
||||
["DO_NumDossier"] = "DOS-001",
|
||||
["DO_DateDebut"] = new DateTime(2026, 1, 1),
|
||||
["GUID"] = "11111111-2222-3333-4444-555555555555",
|
||||
["CL_GUID"] = clientGuid,
|
||||
["CL_Nom"] = clientNom
|
||||
};
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
public IReadOnlyDictionary<string, object?> DossierSearchRowDict { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
{
|
||||
if (ContainsFrom(sql, "Dossier"))
|
||||
{
|
||||
return [DossierSearchRowDict];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
using Omika.Core.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du repository d'identité mobile Omika.
|
||||
/// Vérifie que l'utilisateur est résolu par son identifiant physique (UT_PKID,
|
||||
/// valeur portée par la claim UserId du token) et non par son login, et que la
|
||||
/// chaîne UT_IdRole → Role → AccesEtDroit produit les permissions réelles.
|
||||
/// </summary>
|
||||
public class OmikaMobileIdentityRepositoryTests
|
||||
{
|
||||
private const long UserIdTest = 1001;
|
||||
private const long CollaborateurIdTest = 2001;
|
||||
private const ulong RoleIdTest = 3001;
|
||||
private const string LoginTest = "TEST_USER";
|
||||
private const string RoleLibelleTest = "Test Gestionnaire";
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_ResoutParUT_PKID_EtRetourneALicMob()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
connection.AccesEtDroitRows.Add(DroitRow(RoleIdTest, "A.LIC.MOB", true));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.True(permissions["A.LIC.MOB"]);
|
||||
|
||||
// La résolution utilise UT_PKID (jamais UT_Login) avec un paramètre numérique.
|
||||
string utilisateurSql = connection.Queries.First(q => q.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("UT_PKID = ?", utilisateurSql);
|
||||
Assert.DoesNotContain("UT_Login = ?", utilisateurSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProfileAsync_ResoutLeRole()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
connection.RoleRows.Add(RoleRow(RoleIdTest, RoleLibelleTest));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
MobileProfileDto profile = await repository.GetProfileAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal(RoleLibelleTest, profile.Code);
|
||||
Assert.Equal(RoleLibelleTest, profile.Nom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLoginByUserIdAsync_RetourneLoginReel()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
string? login = await repository.GetLoginByUserIdAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal(LoginTest, login);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_PermissionExplicitementRefusee_RetourneFalse()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
connection.AccesEtDroitRows.Add(DroitRow(RoleIdTest, "A.LIC.MOB", false));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.False(permissions["A.LIC.MOB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_PermissionAbsente_RetourneFalse()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
// Aucune ligne AccesEtDroit pour ce rôle.
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.False(permissions["A.LIC.MOB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_UT_IdRoleZero_AucunePermissionEtPasDeCrash()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, roleId: 0));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.False(permissions["A.LIC.MOB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProfileAsync_RoleInexistant_RetourneProfilVideSansCrash()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, RoleIdTest));
|
||||
// Aucune ligne Role pour RoleIdTest.
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
MobileProfileDto profile = await repository.GetProfileAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Null(profile.Code);
|
||||
Assert.Null(profile.Nom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_UtilisateurInconnu_AucunePermission()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
// Aucune ligne Utilisateur.
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.False(permissions["A.LIC.MOB"]);
|
||||
Assert.Null(await repository.GetLoginByUserIdAsync(UserIdTest.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_GrandIdentifiant64Bits_PasDeTroncature()
|
||||
{
|
||||
const long bigUserId = 9_000_000_000; // > Int32.MaxValue
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(bigUserId, LoginTest, RoleIdTest));
|
||||
connection.AccesEtDroitRows.Add(DroitRow(RoleIdTest, "A.LIC.MOB", true));
|
||||
var repository = new OmikaMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(bigUserId.ToString());
|
||||
|
||||
Assert.True(permissions["A.LIC.MOB"]);
|
||||
Assert.Contains(connection.Queries, q =>
|
||||
q.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Le paramètre passé est bien le long complet (aucune conversion 32 bits).
|
||||
var utilisateurCall = connection.Calls.First(c => c.Sql.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(bigUserId, utilisateurCall.Parameters![0]);
|
||||
}
|
||||
|
||||
// ===== Fixtures =====
|
||||
|
||||
private static Dictionary<string, object?> UtilisateurRow(long pkId, string login, ulong roleId) => new()
|
||||
{
|
||||
["UT_PKID"] = pkId,
|
||||
["UT_Login"] = login,
|
||||
["UT_IDCollaborateur"] = CollaborateurIdTest,
|
||||
["UT_IdRole"] = roleId,
|
||||
["UT_AccesMobile"] = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> RoleRow(ulong roleId, string libelle) => new()
|
||||
{
|
||||
["R_PKID"] = (long)roleId,
|
||||
["R_Libelle"] = libelle
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> DroitRow(ulong roleId, string cleAcces, bool acces) => new()
|
||||
{
|
||||
["AED_PKID"] = 4001L,
|
||||
["AED_IdRole"] = roleId,
|
||||
["AED_CleAcces"] = cleAcces,
|
||||
["AED_Acces"] = acces
|
||||
};
|
||||
|
||||
// ===== Fake =====
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeHfsqlConnection(HfsqlConnectionState state) => State = state;
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public List<Dictionary<string, object?>> UtilisateurRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> RoleRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> AccesEtDroitRows { get; } = [];
|
||||
|
||||
public List<string> Queries { get; } = [];
|
||||
public List<(string Sql, IReadOnlyList<object?>? Parameters)> Calls { get; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(sql);
|
||||
Calls.Add((sql, parameters));
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> result = sql switch
|
||||
{
|
||||
_ when sql.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase) => UtilisateurRows,
|
||||
_ when sql.Contains("FROM Role", StringComparison.OrdinalIgnoreCase) => RoleRows,
|
||||
_ when sql.Contains("FROM AccesEtDroit", StringComparison.OrdinalIgnoreCase) => AccesEtDroitRows,
|
||||
_ => []
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de non-régression sur les requêtes SQL du
|
||||
/// <see cref="OmikaRapportChampRepository"/> (constantes privées lues par
|
||||
/// réflexion). Ils verrouillent la couverture des colonnes INTER/DOSSIER (alignée
|
||||
/// sur le WINDEV Omika <c>saRQ</c>/<c>sARQDossier</c>) et les gardes contre
|
||||
/// l'explosion de jointure sur clé métier vide (<c>PA_Cle = ''</c>).
|
||||
/// </summary>
|
||||
public class OmikaRapportChampSqlContractTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("InterventionSql", "IN_Titre")]
|
||||
[InlineData("InterventionSql", "IN_RefInterne")]
|
||||
[InlineData("InterventionSql", "IN_CreationDate")]
|
||||
[InlineData("InterventionSql", "IN_NumDevis")]
|
||||
[InlineData("InterventionSql", "IN_DateDevis")]
|
||||
[InlineData("InterventionSql", "IN_NumFacture")]
|
||||
[InlineData("InterventionSql", "IN_DateFacture")]
|
||||
[InlineData("InterventionSql", "IN_RefExterne")]
|
||||
[InlineData("InterventionSql", "IN_Description")]
|
||||
[InlineData("InterventionSql", "IN_DureePrevue")]
|
||||
[InlineData("InterventionSql", "IN_CO_Nom")]
|
||||
[InlineData("InterventionSql", "IN_PA_Type")]
|
||||
[InlineData("InterventionSql", "LI_CodeLieu")]
|
||||
[InlineData("InterventionSql", "LI_Adresse")]
|
||||
[InlineData("InterventionSql", "LI_CodeAcces")]
|
||||
[InlineData("InterventionSql", "CL_Nom")]
|
||||
[InlineData("InterventionSql", "AD_Presentation")]
|
||||
[InlineData("InterventionSql", "SO_Nom")]
|
||||
public void InterventionSql_ContientColonne(string field, string column)
|
||||
=> Assert.True(ReadConst(field).Contains(column, StringComparison.OrdinalIgnoreCase),
|
||||
$"{field} doit sélectionner {column}.");
|
||||
|
||||
[Fact]
|
||||
public void DossierSql_ContientStatut()
|
||||
=> Assert.True(ReadConst("DossierSql").Contains("DOSSIER_STATUT", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[Theory]
|
||||
[InlineData("ContactsSiteSql")]
|
||||
[InlineData("ContactsDonneursSql")]
|
||||
public void ContactsSql_GardeCleVide(string field)
|
||||
=> Assert.Contains("IP_CleRole <> ''", ReadConst(field));
|
||||
|
||||
[Fact]
|
||||
public void PhotosSql_GardeCleVide()
|
||||
=> Assert.Contains("DC_CleCategorie <> ''", ReadConst("PhotosSql"));
|
||||
|
||||
private static string ReadConst(string name)
|
||||
{
|
||||
FieldInfo? field = typeof(OmikaRapportChampRepository).GetField(
|
||||
name, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
return (string)field!.GetValue(null)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Exceptions;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="OmikaRendezVousRepository"/> : aucun serveur
|
||||
/// HFSQL, aucun ODBC réel, aucun réseau. Un fake <see cref="IHfsqlConnection"/>
|
||||
/// enregistre le SQL et les paramètres et retourne des dictionnaires selon la
|
||||
/// table interrogée.
|
||||
///
|
||||
/// Objectifs :
|
||||
/// <list type="bullet">
|
||||
/// <item>garantir que la validation des temps (<see cref="OmikaRendezVousRepository.SetTempsValideAsync"/>)
|
||||
/// ne modifie QUE l'affectation <c>RDV_Collaborateur</c> du collaborateur ciblé
|
||||
/// (<c>RVC_GUID_Colb</c>), jamais les autres affectations d'un RDV groupe ;</item>
|
||||
/// <item>garantir que la validation des temps d'un RDV individuel met à jour
|
||||
/// l'affectation ET le noyau <c>RDV_Planning</c> (statut « Fait » dans les deux
|
||||
/// enregistrements), alors qu'un RDV groupe ne met à jour que l'affectation ;</item>
|
||||
/// <item>garantir que l'annulation d'un RDV groupe sans collaborateur ne produit
|
||||
/// AUCUNE écriture et lève une erreur explicite ;</item>
|
||||
/// <item>garantir que l'annulation d'un RDV groupe annule TOUTES les affectations
|
||||
/// ET le noyau <c>RDV_Planning</c> (statut + origine + motif + description) :
|
||||
/// tous les rendez-vous du groupe sont impactés ;</item>
|
||||
/// <item>garantir que l'annulation d'un RDV individuel modifie l'affectation ET le
|
||||
/// noyau <c>RDV_Planning</c> (statut + origine + motif + description) ;</item>
|
||||
/// <item>garantir que la description reçoit la mention d'annulation
|
||||
/// <c>Note (Nom) : message</c>, le nom étant résolu côté serveur s'il n'est pas
|
||||
/// fourni, sans jamais dupliquer une mention déjà présente (rejeu de
|
||||
/// synchronisation).</item>
|
||||
/// <item>garantir que TOUTE modification du noyau (annulation, statut « Fait »,
|
||||
/// archivage) porte l'estampille d'audit <c>ModifDate</c> / <c>ModifUser</c>,
|
||||
/// avec un nombre de paramètres toujours égal au nombre de <c>?</c> du SQL.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class OmikaRendezVousRepositoryTests
|
||||
{
|
||||
private const string RdvGuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
private const string CollaborateurAGuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
private const string DescriptionInitiale = "Description initiale";
|
||||
|
||||
// =========================================================================
|
||||
// VALIDATION DES TEMPS : AFFECTATION + NOYAU (RDV INDIVIDUEL)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SetTempsValideAsync_RdvIndividuel_ValideAffectationEtPlanning()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
// Une seule affectation => RDV individuel.
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool updated = await repository.SetTempsValideAsync(
|
||||
RdvGuid, CollaborateurAGuid, tempsValide: true);
|
||||
|
||||
Assert.True(updated);
|
||||
|
||||
// Deux écritures : l'affectation (RVC_TempsValide + RVC_CleStatut = Fait)
|
||||
// puis le noyau RDV_Planning (RV_CleStatut = Fait), dans UNE transaction.
|
||||
ExecutedCommand affectation = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Collaborateur", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("RVC_GUID_RDV IN (", affectation.Sql);
|
||||
Assert.Contains("RVC_GUID_Colb IN (", affectation.Sql);
|
||||
Assert.Contains("RVC_TempsValide = ?", affectation.Sql);
|
||||
Assert.Contains("RVC_CleStatut = 'S.RDV.FAIT'", affectation.Sql);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("RV_CleStatut = ?", planning.Sql);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// VALIDATION DES TEMPS : RDV GROUPE → UNIQUEMENT L'AFFECTATION
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SetTempsValideAsync_RdvGroupe_CibleUniquementAffectationDuCollaborateur()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
// Deux affectations => RDV groupe. Aucune n'exige de compte rendu
|
||||
// (RVC_CR = 0 par défaut) : la validation aboutit à l'écriture.
|
||||
CollaborateurRows = [CollaborateurRow(1L), CollaborateurRow(2L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool updated = await repository.SetTempsValideAsync(
|
||||
RdvGuid, CollaborateurAGuid, tempsValide: true);
|
||||
|
||||
Assert.True(updated);
|
||||
|
||||
// L'unique écriture cible l'affectation du collaborateur (RVC_GUID_Colb).
|
||||
// Le noyau RDV_Planning d'un RDV groupe n'est JAMAIS modifié.
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
Assert.Contains("UPDATE RDV_Collaborateur", write.Sql);
|
||||
Assert.Contains("RVC_GUID_RDV IN (", write.Sql);
|
||||
Assert.Contains("RVC_GUID_Colb IN (", write.Sql);
|
||||
Assert.Contains("RVC_TempsValide = ?", write.Sql);
|
||||
Assert.DoesNotContain(connection.Writes, c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTempsValideAsync_Invalidation_AbaisseUniquementTempsValide()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool updated = await repository.SetTempsValideAsync(
|
||||
RdvGuid, CollaborateurAGuid, tempsValide: false);
|
||||
|
||||
Assert.True(updated);
|
||||
|
||||
// Invalidation : seul le drapeau RVC_TempsValide est abaissé (le statut
|
||||
// n'est pas remis en cause, ni côté affectation ni côté noyau).
|
||||
ExecutedCommand write = Assert.Single(connection.Writes);
|
||||
Assert.Contains("UPDATE RDV_Collaborateur", write.Sql);
|
||||
Assert.Contains("RVC_TempsValide = ?", write.Sql);
|
||||
Assert.DoesNotContain("RVC_CleStatut", write.Sql);
|
||||
Assert.DoesNotContain(connection.Writes, c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ANNULATION D'UN RDV GROUPE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_RdvGroupe_SansCollaborateur_LeveErreurEtAucuneEcriture()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
// Deux affectations => RDV groupe.
|
||||
CollaborateurRows = [CollaborateurRow(1L), CollaborateurRow(2L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
var annulation = new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "note",
|
||||
CollaborateurId = null
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainValidationException>(
|
||||
() => repository.CancelAsync(RdvGuid, annulation));
|
||||
|
||||
Assert.Contains("collaborateur", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Aucune ligne RDV_Collaborateur ni RDV_Planning ne doit être modifiée.
|
||||
Assert.Empty(connection.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_RdvGroupe_AnnuleToutesLesAffectationsEtLeNoyau()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
// Deux affectations (A et B) => RDV groupe.
|
||||
CollaborateurRows = [CollaborateurRow(1L), CollaborateurRow(2L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
var annulation = new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurId = CollaborateurAGuid,
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
};
|
||||
|
||||
bool cancelled = await repository.CancelAsync(RdvGuid, annulation);
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
// 1. TOUTES les affectations du rendez-vous sont annulées : aucun ciblage
|
||||
// par RVC_GUID_Colb, car tous les rendez-vous du groupe sont impactés.
|
||||
ExecutedCommand affectation = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Collaborateur", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("UPDATE RDV_Collaborateur", affectation.Sql);
|
||||
Assert.Contains("RVC_CleStatut = ?", affectation.Sql);
|
||||
Assert.Contains("RVC_GUID_RDV IN (", affectation.Sql);
|
||||
Assert.DoesNotContain("RVC_GUID_Colb", affectation.Sql);
|
||||
Assert.Equal("S.RDV.ANNU", affectation.Parameters[0]);
|
||||
|
||||
// 2. Le noyau RDV_Planning est annulé comme pour un RDV individuel :
|
||||
// statut + origine + motif + description.
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("RV_CleStatut = ?", planning.Sql);
|
||||
Assert.Contains("RV_CleOrigineAnnu = ?", planning.Sql);
|
||||
Assert.Contains("RV_CleMotifAnnu = ?", planning.Sql);
|
||||
Assert.Contains("RV_Description = ?", planning.Sql);
|
||||
|
||||
Assert.Equal("S.RDV.ANNU", planning.Parameters[0]);
|
||||
Assert.Equal("O1", planning.Parameters[1]);
|
||||
Assert.Equal("M1", planning.Parameters[2]);
|
||||
|
||||
// 3. La description porte la mention d'annulation « Note (Nom) : message ».
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
planning.Parameters[3] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SansNomCollaborateur_ResoutLeNomEnBase()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(1L)],
|
||||
// Le client n'a pas transmis le nom : il est résolu côté serveur
|
||||
// depuis l'identité publique du collaborateur (Collaborateur.GUID).
|
||||
CollaborateurNameRows =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["GUID"] = CollaborateurAGuid,
|
||||
["CO_Nom"] = "DUPONT",
|
||||
["CO_Prenom"] = "Jean"
|
||||
}
|
||||
]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
var annulation = new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurId = CollaborateurAGuid
|
||||
};
|
||||
|
||||
bool cancelled = await repository.CancelAsync(RdvGuid, annulation);
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
planning.Parameters[3] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_NoteDejaPresente_NeLaDupliquePas()
|
||||
{
|
||||
// Rejeu d'une opération de synchronisation dont la réponse a été perdue :
|
||||
// la note est déjà dans la description, elle n'est pas ajoutée deux fois.
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["RV_PKID"] = 42L,
|
||||
["RV_Description"] = DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent"
|
||||
}
|
||||
],
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
var annulation = new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "Client absent",
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
};
|
||||
|
||||
bool cancelled = await repository.CancelAsync(RdvGuid, annulation);
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
planning.Parameters[3] as string);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ANNULATION D'UN RDV INDIVIDUEL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_RdvIndividuel_ModifieAffectationEtPlanning()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
// Une seule affectation => RDV individuel.
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
var annulation = new AnnulationRendezVousCommand
|
||||
{
|
||||
OrigineKey = "O1",
|
||||
MotifKey = "M1",
|
||||
Commentaire = "note",
|
||||
CollaborateurId = null,
|
||||
CollaborateurNom = "Jean DUPONT"
|
||||
};
|
||||
|
||||
bool cancelled = await repository.CancelAsync(RdvGuid, annulation);
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
// Deux écritures : l'affectation (ciblée par le GUID du RDV seul) puis le
|
||||
// noyau RDV_Planning (statut annulé + origine + motif + description).
|
||||
ExecutedCommand affectation = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Collaborateur", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("UPDATE RDV_Collaborateur", affectation.Sql);
|
||||
Assert.Contains("RVC_CleStatut = ?", affectation.Sql);
|
||||
Assert.DoesNotContain("RVC_GUID_Colb", affectation.Sql);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
Assert.Contains("RV_CleStatut = ?", planning.Sql);
|
||||
Assert.Contains("RV_CleOrigineAnnu = ?", planning.Sql);
|
||||
Assert.Contains("RV_CleMotifAnnu = ?", planning.Sql);
|
||||
Assert.Contains("RV_Description = ?", planning.Sql);
|
||||
|
||||
Assert.Equal(
|
||||
DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : note",
|
||||
planning.Parameters[3] as string);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ESTAMPILLE D'AUDIT (ModifDate / ModifUser) DU NOYAU
|
||||
//
|
||||
// L'annulation, le passage au statut « Fait » et l'archivage MODIFIENT le
|
||||
// rendez-vous : l'enregistrement RDV_Planning doit donc porter la même
|
||||
// estampille d'audit que toute autre écriture. Sans elle, la modification
|
||||
// change la donnée sans laisser de trace (ModifDate/ModifUser figés).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_AvecAuteur_EstampilleModifDateEtModifUserDuNoyau()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync(
|
||||
RdvGuid,
|
||||
new AnnulationRendezVousCommand { Commentaire = "note", CollaborateurNom = "Jean DUPONT" },
|
||||
auteur: new AuditActor("77", CollaborateurAGuid));
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
AssertParametersMatchPlaceholders(planning);
|
||||
Assert.Contains("ModifDate = ?", planning.Sql);
|
||||
Assert.Contains("ModifUser = ?", planning.Sql);
|
||||
|
||||
// Statut, origine, motif, description, ModifDate, ModifUser, puis le GUID.
|
||||
Assert.Equal("S.RDV.ANNU", planning.Parameters[0]);
|
||||
Assert.IsType<DateTime>(planning.Parameters[4]);
|
||||
Assert.Equal(77L, planning.Parameters[5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SansAuteur_EstampilleModifDateSansEcraserModifUser()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(1L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool cancelled = await repository.CancelAsync(
|
||||
RdvGuid,
|
||||
new AnnulationRendezVousCommand { Commentaire = "note", CollaborateurNom = "Jean DUPONT" });
|
||||
|
||||
Assert.True(cancelled);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
AssertParametersMatchPlaceholders(planning);
|
||||
|
||||
// Aucun auteur résolvable : ModifDate est écrit, mais ModifUser n'est
|
||||
// jamais écrasé par 0 (l'utilisateur déjà enregistré est conservé).
|
||||
Assert.Contains("ModifDate = ?", planning.Sql);
|
||||
Assert.DoesNotContain("ModifUser", planning.Sql);
|
||||
Assert.IsType<DateTime>(planning.Parameters[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTempsValideAsync_RdvIndividuel_EstampilleModifDateEtModifUserDuNoyau()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(1L)],
|
||||
// Auteur fourni sous forme de GUID collaborateur : l'utilisateur
|
||||
// physique (UT_PKID) est résolu en base.
|
||||
UtilisateurRows = [new Dictionary<string, object?> { ["UT_PKID"] = 55L }]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool updated = await repository.SetTempsValideAsync(
|
||||
RdvGuid, CollaborateurAGuid, tempsValide: true, auteur: new AuditActor(null, CollaborateurAGuid));
|
||||
|
||||
Assert.True(updated);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
AssertParametersMatchPlaceholders(planning);
|
||||
Assert.Contains("ModifDate = ?", planning.Sql);
|
||||
Assert.Contains("ModifUser = ?", planning.Sql);
|
||||
|
||||
// Statut « Fait », ModifDate, ModifUser, puis le GUID du rendez-vous.
|
||||
Assert.Equal("S.RDV.FAIT", planning.Parameters[0]);
|
||||
Assert.IsType<DateTime>(planning.Parameters[1]);
|
||||
Assert.Equal(55L, planning.Parameters[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_AvecAuteur_EstampilleModifDateEtModifUserDuNoyau()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection { PlanningRows = [PlanningRow(42L)] };
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
bool archived = await repository.ArchiveAsync(RdvGuid, auteur: new AuditActor("77", null));
|
||||
|
||||
Assert.True(archived);
|
||||
|
||||
ExecutedCommand planning = Assert.Single(connection.Writes.Where(c =>
|
||||
c.Sql.Contains("RDV_Planning", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
AssertParametersMatchPlaceholders(planning);
|
||||
Assert.Contains("Archive = ?", planning.Sql);
|
||||
Assert.Contains("ModifDate = ?", planning.Sql);
|
||||
Assert.Contains("ModifUser = ?", planning.Sql);
|
||||
|
||||
Assert.Equal(true, planning.Parameters[0]);
|
||||
Assert.IsType<DateTime>(planning.Parameters[1]);
|
||||
Assert.Equal(77L, planning.Parameters[2]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AFFECTATIONS ARCHIVÉES : COLLABORATEUR RETIRÉ DU RENDEZ-VOUS
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Règle métier : retirer un collaborateur d'un rendez-vous groupé archive sa
|
||||
/// ligne <c>RDV_Collaborateur</c> sans supprimer le noyau (qui reste porté par
|
||||
/// les autres collaborateurs). Les lectures du rendez-vous doivent donc
|
||||
/// exclure les affectations archivées : sans ce filtre, le collaborateur
|
||||
/// retiré continuait d'être renvoyé comme participant et réapparaissait sur le
|
||||
/// mobile dans le bloc « rendez-vous groupé ».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SearchDetailsAsync_ExclutLesAffectationsArchivees()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [OmikaPlanningRow(42L, RdvGuid)],
|
||||
CollaborateurRows = [CollaborateurRow(7L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
// Act
|
||||
await repository.SearchDetailsAsync(new RendezVousSearchCriteria { Page = 1, PageSize = 10 });
|
||||
|
||||
// Assert : la lecture des affectations ne ramène que les lignes actives.
|
||||
ExecutedCommand query = Assert.Single(connection.Queries
|
||||
.Where(c => c.Sql.Contains("FROM RDV_Collaborateur", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList());
|
||||
|
||||
Assert.Contains("Archive = ?", query.Sql);
|
||||
AssertParametersMatchPlaceholders(query);
|
||||
Assert.Equal(false, query.Parameters[^1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même règle à l'annulation : un rendez-vous dont le collaborateur a été
|
||||
/// retiré redevient INDIVIDUEL (une seule affectation active), il ne doit donc
|
||||
/// plus être traité comme un rendez-vous de groupe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CancelAsync_IgnoreLesAffectationsArchiveesPourDetecterLeGroupe()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow(42L)],
|
||||
CollaborateurRows = [CollaborateurRow(7L)]
|
||||
};
|
||||
var repository = new OmikaRendezVousRepository(connection);
|
||||
|
||||
// Act : annulation SANS collaborateur — autorisée si le RDV est individuel.
|
||||
bool cancelled = await repository.CancelAsync(
|
||||
RdvGuid,
|
||||
new AnnulationRendezVousCommand { Commentaire = "Absent" },
|
||||
transaction: null);
|
||||
|
||||
// Assert
|
||||
Assert.True(cancelled);
|
||||
ExecutedCommand query = Assert.Single(connection.Queries
|
||||
.Where(c => c.Sql.Contains("FROM RDV_Collaborateur", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList());
|
||||
|
||||
Assert.Contains("Archive = ?", query.Sql);
|
||||
AssertParametersMatchPlaceholders(query);
|
||||
Assert.Equal(false, query.Parameters[^1]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie que le nombre de paramètres positionnels correspond EXACTEMENT au
|
||||
/// nombre de <c>?</c> du SQL : une estampille d'audit oubliée (ou en trop)
|
||||
/// décale les paramètres et corrompt silencieusement l'écriture.
|
||||
/// </summary>
|
||||
private static void AssertParametersMatchPlaceholders(ExecutedCommand command)
|
||||
{
|
||||
int placeholders = command.Sql.Count(c => c == '?');
|
||||
Assert.Equal(placeholders, command.Parameters.Count);
|
||||
}
|
||||
|
||||
/// <summary>Ligne <c>RDV_Planning</c> complète (lecture détaillée).</summary>
|
||||
private static Dictionary<string, object?> OmikaPlanningRow(long pkid, string guid)
|
||||
=> new()
|
||||
{
|
||||
["RV_PKID"] = pkid,
|
||||
["GUID"] = guid,
|
||||
["RV_Objet"] = "Chantier groupé",
|
||||
["RV_Date"] = new DateOnly(2026, 10, 12),
|
||||
["RV_CleType"] = null,
|
||||
["RV_CleStatut"] = null,
|
||||
["RV_IdLieu"] = 0L,
|
||||
["RV_IdDossier"] = 0L,
|
||||
["RV_IdInter"] = 0L,
|
||||
["RV_IdSociete"] = 0L,
|
||||
["RV_CleOrigineAnnu"] = null,
|
||||
["RV_CleMotifAnnu"] = null,
|
||||
["Archive"] = false,
|
||||
["RV_SAV"] = false
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> PlanningRow(long pkid)
|
||||
=> new()
|
||||
{
|
||||
["RV_PKID"] = pkid,
|
||||
["RV_Description"] = DescriptionInitiale
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> CollaborateurRow(long pkid)
|
||||
=> new() { ["RVC_PKID"] = pkid };
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
/// <summary>Toutes les commandes (lectures ET écritures) dans l'ordre.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Queries => _commands;
|
||||
|
||||
/// <summary>Écritures (ExecuteNonQuery) dans l'ordre d'exécution.</summary>
|
||||
public IReadOnlyList<ExecutedCommand> Writes => _writes;
|
||||
private readonly List<ExecutedCommand> _writes = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM RDV_Planning</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PlanningRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM RDV_Collaborateur</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Collaborateur</c> (annuaire).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurNameRows { get; set; } = [];
|
||||
|
||||
/// <summary>Lignes retournées pour <c>FROM Utilisateur</c> (résolution de l'audit).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> UtilisateurRows { get; set; } = [];
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []);
|
||||
_commands.Add(command);
|
||||
_writes.Add(command);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
||||
{
|
||||
if (ContainsFrom(sql, "RDV_Planning"))
|
||||
return PlanningRows;
|
||||
if (ContainsFrom(sql, "RDV_Collaborateur"))
|
||||
return CollaborateurRows;
|
||||
if (ContainsFrom(sql, "Utilisateur"))
|
||||
return UtilisateurRows;
|
||||
if (ContainsFrom(sql, "Collaborateur"))
|
||||
return CollaborateurNameRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
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,274 @@
|
||||
using Omika.Core.Domain.Entities.Clients;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories.Omika;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="OmikaSiteRepository"/> : aucun serveur HFSQL,
|
||||
/// aucun driver PC SOFT, aucun ODBC réel, aucun réseau. Un fake
|
||||
/// <see cref="IHfsqlConnection"/> enregistre le SQL et les paramètres et
|
||||
/// retourne des dictionnaires selon la requête.
|
||||
///
|
||||
/// Objectif principal : verrouiller que la résolution par GUID de
|
||||
/// <c>ResolveExistingAsync</c> couvre la forme « D tronquée » (32 caractères
|
||||
/// avec tirets, sans les 4 derniers hexadécimaux) réellement stockée dans
|
||||
/// <c>Lieu.GUID</c>, via <c>PhysicalGuidParser.GetStorageCandidates</c>.
|
||||
/// </summary>
|
||||
public class OmikaSiteRepositoryTests
|
||||
{
|
||||
// =========================================================================
|
||||
// RESOLUTION PAR GUID
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_GuidPublicAvec0000_TrouveLaFormeTronqueePhysique()
|
||||
{
|
||||
const string publicGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc0000"; // forme D reconstruite
|
||||
const string physicalGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc"; // forme D tronquée stockée
|
||||
const long pkid = 281474976713227L;
|
||||
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = physicalGuid,
|
||||
LieuRowDict = LieuRow(pkid, physicalGuid, "69600_452 TROIS CHENE")
|
||||
};
|
||||
var repository = new OmikaSiteRepository(connection);
|
||||
|
||||
Site? site = await repository.GetDetailsAsync(publicGuid);
|
||||
|
||||
Assert.NotNull(site);
|
||||
Assert.NotNull(site!.SourceReference);
|
||||
Assert.Equal(pkid, site.SourceReference!.NumericId!.Value);
|
||||
Assert.Equal(Guid.Parse(publicGuid), site.SourceReference.EntityGuid!.Value);
|
||||
|
||||
// La forme tronquée a bien été testée (et non seulement D / N).
|
||||
Assert.Contains(connection.Commands, c =>
|
||||
c.Sql.Contains("FROM Lieu WHERE GUID = ?", StringComparison.OrdinalIgnoreCase)
|
||||
&& c.Parameters.Count == 1
|
||||
&& string.Equals(c.Parameters[0] as string, physicalGuid, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_GuidStandardD_EstRetrouve()
|
||||
{
|
||||
const string guid = "11111111-2222-3333-4444-555555555555";
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = guid,
|
||||
LieuRowDict = LieuRow(42L, guid, "SITE-D")
|
||||
};
|
||||
var repository = new OmikaSiteRepository(connection);
|
||||
|
||||
Site? site = await repository.GetDetailsAsync(guid);
|
||||
|
||||
Assert.NotNull(site);
|
||||
Assert.Equal(42L, site!.SourceReference!.NumericId!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_GuidStandardN_EstRetrouve()
|
||||
{
|
||||
const string guidN = "0b0cfe4f5bd84a698c67802c79bc0000";
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = guidN,
|
||||
LieuRowDict = LieuRow(43L, guidN, "SITE-N")
|
||||
};
|
||||
var repository = new OmikaSiteRepository(connection);
|
||||
|
||||
Site? site = await repository.GetDetailsAsync(guidN);
|
||||
|
||||
Assert.NotNull(site);
|
||||
Assert.Equal(43L, site!.SourceReference!.NumericId!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_GuidInexistant_RetourneNull()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc",
|
||||
LieuRowDict = LieuRow(281474976713227L, "0b0cfe4f-5bd8-4a69-8c67-802c79bc", "SITE")
|
||||
};
|
||||
var repository = new OmikaSiteRepository(connection);
|
||||
|
||||
Site? site = await repository.GetDetailsAsync("99999999-8888-7777-6666-555555555555");
|
||||
|
||||
Assert.Null(site);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RESOLUTION PAR PK NUMERIQUE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetailsAsync_PkNumerique_EstRetrouve()
|
||||
{
|
||||
const long pkid = 281474976713227L;
|
||||
const string physicalGuid = "0b0cfe4f-5bd8-4a69-8c67-802c79bc";
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
StoredPkid = pkid,
|
||||
LieuRowDict = LieuRow(pkid, physicalGuid, "69600_452 TROIS CHENE")
|
||||
};
|
||||
var repository = new OmikaSiteRepository(connection);
|
||||
|
||||
Site? site = await repository.GetDetailsAsync(pkid.ToString());
|
||||
|
||||
Assert.NotNull(site);
|
||||
Assert.Equal(pkid, site!.SourceReference!.NumericId!.Value);
|
||||
Assert.Contains(connection.Commands, c =>
|
||||
c.Sql.Contains("FROM Lieu WHERE LI_PKID = ?", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static Dictionary<string, object?> LieuRow(long pkid, string? guid, string? code = null)
|
||||
=> new()
|
||||
{
|
||||
["LI_PKID"] = pkid,
|
||||
["LI_CodeLieu"] = code,
|
||||
["LI_IDAdresse"] = 0L,
|
||||
["LI_IDType"] = 0L,
|
||||
["LI_CodeAcces"] = null,
|
||||
["LI_Badge"] = false,
|
||||
["LI_Interphone"] = false,
|
||||
["LI_Note"] = null,
|
||||
["Archive"] = false,
|
||||
["LI_IDImport"] = null,
|
||||
["LI_RepertoireDocs"] = null,
|
||||
["LI_IDClientPrincipal"] = 0L,
|
||||
["LI_Blocage"] = false,
|
||||
["LI_RaisonBlocage"] = null,
|
||||
["LI_CleType"] = null,
|
||||
["GUID"] = guid,
|
||||
["CreationDate"] = DateTime.MinValue,
|
||||
["ModifDate"] = DateTime.MinValue,
|
||||
["DateArchivage"] = DateTime.MinValue
|
||||
};
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
||||
private readonly List<ExecutedCommand> _commands = [];
|
||||
|
||||
/// <summary>Valeur GUID physique attendue pour <c>FROM Lieu WHERE GUID = ?</c>.</summary>
|
||||
public string? StoredGuid { get; set; }
|
||||
|
||||
/// <summary>PK attendu pour <c>FROM Lieu WHERE LI_PKID = ?</c>.</summary>
|
||||
public long StoredPkid { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> LieuRowDict { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
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)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
||||
return Task.FromResult(ResolveRows(sql, parameters));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters)
|
||||
{
|
||||
if (ContainsFrom(sql, "Lieu"))
|
||||
{
|
||||
if (sql.Contains("WHERE GUID = ?", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StoredGuid is not null
|
||||
&& parameters is { Count: > 0 }
|
||||
&& string.Equals(parameters[0] as string, StoredGuid, StringComparison.Ordinal)
|
||||
? [LieuRowDict]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (sql.Contains("WHERE LI_PKID = ?", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return StoredPkid > 0
|
||||
&& parameters is { Count: > 0 }
|
||||
&& parameters[0] is long pk
|
||||
&& pk == StoredPkid
|
||||
? [LieuRowDict]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ParametreMetier / Adresse : référentiels vides (site sans type ni adresse).
|
||||
return [];
|
||||
}
|
||||
|
||||
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,130 @@
|
||||
using System.Reflection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.HfSql.Models.Omika;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Garde-fou structurel : chaque colonne référencée par les SELECT des
|
||||
/// repositories Omika doit exister dans le modèle physique de la Row cible
|
||||
/// (attribut <see cref="HfSqlColumnAttribute.ColumnName"/>).
|
||||
///
|
||||
/// Contexte : la recherche mobile (lot 36C) échouait en HTTP 500 sur
|
||||
/// OMIKA_TEST car <see cref="OmikaRendezVousRepository"/> sélectionnait des
|
||||
/// colonnes inexistantes dans le schéma Omika (ex. RVC_IDRdv, RVCT_IDRdv,
|
||||
/// RVC_GUIDColb au lieu de RVC_GUID_RDV, RVC_GUID_Colb...). Ce test reproduit
|
||||
/// ce défaut sans base réelle : il vérifie la cohérence SQL ↔ modèle.
|
||||
/// </summary>
|
||||
public class OmikaSqlColumnConsistencyTests
|
||||
{
|
||||
public static IEnumerable<object[]> Cases()
|
||||
{
|
||||
// Recherche mobile (enrichissement) : rendez-vous, clients, sites, interventions.
|
||||
yield return [typeof(OmikaRendezVousRepository), "SqlSelectRendezVousColumns", typeof(RDVPlanningRow)];
|
||||
yield return [typeof(OmikaRendezVousRepository), "SqlSelectCollaborateurColumns", typeof(RDVCollaborateurRow)];
|
||||
yield return [typeof(OmikaRendezVousRepository), "SqlSelectContactColumns", typeof(RDVContactRow)];
|
||||
yield return [typeof(OmikaRendezVousRepository), "SqlSelectParametreMetierColumns", typeof(ParametreMetierRow)];
|
||||
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectClientColumns", typeof(ClientRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectAdresseColumns", typeof(AdresseRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectLieuColumns", typeof(LieuRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectPersonneColumns", typeof(PersonneRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectParametreMetierColumns", typeof(ParametreMetierRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectClientAdresseColumns", typeof(ClientAdresseRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectClientLieuColumns", typeof(ClientLieuRow)];
|
||||
yield return [typeof(OmikaClientRepository), "SqlSelectLieuPersonneColumns", typeof(LieuPersonneRow)];
|
||||
|
||||
yield return [typeof(OmikaSiteRepository), "SqlSelectLieuColumns", typeof(LieuRow)];
|
||||
|
||||
yield return [typeof(OmikaInterventionRepository), "SqlSelectInterventionColumns", typeof(InterventionRow)];
|
||||
yield return [typeof(OmikaInterventionRepository), "SqlSelectParametreMetierColumns", typeof(ParametreMetierRow)];
|
||||
yield return [typeof(OmikaInterventionRepository), "SqlSelectInterventionPersonneColumns", typeof(InterventionPersonneRow)];
|
||||
yield return [typeof(OmikaInterventionRepository), "SqlSelectEtatInterventionColumns", typeof(EtatInterventionRow)];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Cases))]
|
||||
public void ColonnesSelect_ExistentDansLeModelePhysique(Type repositoryType, string fieldName, Type rowType)
|
||||
{
|
||||
string sql = GetPrivateConstString(repositoryType, fieldName);
|
||||
var physicalColumns = GetPhysicalColumns(rowType);
|
||||
|
||||
foreach (string column in ParseColumns(sql))
|
||||
{
|
||||
Assert.True(
|
||||
physicalColumns.Contains(column, StringComparer.OrdinalIgnoreCase),
|
||||
$"{repositoryType.Name}.{fieldName} référence la colonne « {column} » absente du modèle {rowType.Name}.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendezVousRepository_GetDetailsAsync_NUtilisePasDeColonneNumeriqueFantome()
|
||||
{
|
||||
string source = ReadRendezVousRepositorySource();
|
||||
|
||||
// Colonnes de liaison numériques fantômes interdites : elles proviennent
|
||||
// d'une transposition incorrecte du modèle Batik (*_IDRdv) alors que le
|
||||
// modèle Omika relie les tables satellites du rendez-vous par GUID.
|
||||
Assert.DoesNotContain("RVC_IDRdv", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("RVCT_IDRdv", source, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// GetDetailsAsync doit adresser RDV_Collaborateur et RDV_Contact par leur
|
||||
// GUID de liaison (RVC_GUID_RDV / RVCT_GUID_RDV), avec le GUID du
|
||||
// rendez-vous racine (rendezVousRow.GUID) — exactement comme SearchDetailsAsync.
|
||||
// Les affectations ARCHIVÉES sont exclues : un collaborateur retiré du
|
||||
// rendez-vous ne fait plus partie du groupe.
|
||||
Assert.Contains("FROM RDV_Collaborateur", source, StringComparison.Ordinal);
|
||||
Assert.Contains("WHERE RVC_GUID_RDV = ? AND Archive = ?", source, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM RDV_Contact WHERE RVCT_GUID_RDV = ?", source, StringComparison.Ordinal);
|
||||
Assert.Contains("[rendezVousRow.GUID, false]", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadRendezVousRepositorySource()
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(
|
||||
root, "src", "Omika.Data", "Repositories", "Omika", "OmikaRendezVousRepository.cs"));
|
||||
}
|
||||
|
||||
private static string GetPrivateConstString(Type repositoryType, string fieldName)
|
||||
{
|
||||
FieldInfo? field = repositoryType.GetField(
|
||||
fieldName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
|
||||
|
||||
Assert.True(field is not null, $"Champ « {fieldName} » introuvable dans {repositoryType.Name}.");
|
||||
Assert.True(field!.IsLiteral, $"{repositoryType.Name}.{fieldName} n'est pas une constante compile-time.");
|
||||
|
||||
string? value = field.GetRawConstantValue() as string;
|
||||
Assert.False(string.IsNullOrWhiteSpace(value), $"{repositoryType.Name}.{fieldName} est vide.");
|
||||
return value!;
|
||||
}
|
||||
|
||||
private static HashSet<string> GetPhysicalColumns(Type rowType)
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (PropertyInfo property in rowType.GetProperties())
|
||||
{
|
||||
HfSqlColumnAttribute? attr = property.GetCustomAttribute<HfSqlColumnAttribute>();
|
||||
if (attr is not null)
|
||||
{
|
||||
columns.Add(attr.ColumnName);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotEmpty(columns);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseColumns(string sql)
|
||||
=> sql
|
||||
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
using System.Reflection;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories;
|
||||
|
||||
namespace Omika.Tests.Repositories;
|
||||
|
||||
public class RepositoryBaseTests
|
||||
{
|
||||
// --- Constructeur ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullConnection_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new TestRepository(null!));
|
||||
}
|
||||
|
||||
// --- Ouverture de la connexion -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ClosedConnection_OpensBeforeQuery()
|
||||
{
|
||||
var connection = new FakeConnection();
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await repository.QueryAsync("SELECT 1");
|
||||
|
||||
Assert.Equal(1, connection.OpenCount);
|
||||
Assert.Equal("SELECT 1", connection.LastSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpenConnection_DoesNotReopen()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await repository.QueryAsync("SELECT 1");
|
||||
|
||||
Assert.Equal(0, connection.OpenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpenConnection_IsReused()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await repository.QueryAsync("SELECT 1");
|
||||
await repository.QueryAsync("SELECT 2");
|
||||
|
||||
Assert.Equal(0, connection.OpenCount);
|
||||
Assert.Equal(2, connection.QueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpeningState_Throws()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Opening);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.QueryAsync("SELECT 1"));
|
||||
Assert.Equal(0, connection.OpenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ClosingState_Throws()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Closing);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.QueryAsync("SELECT 1"));
|
||||
Assert.Equal(0, connection.OpenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FaultedState_Throws()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Faulted);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.QueryAsync("SELECT 1"));
|
||||
Assert.Equal(0, connection.OpenCount);
|
||||
}
|
||||
|
||||
// --- SQL et paramètres transmis ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_SqlTransmittedIntact()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
const string sql = "SELECT * FROM T WHERE CL_PKID = ?";
|
||||
|
||||
await repository.QueryAsync(sql);
|
||||
|
||||
Assert.Equal(sql, connection.LastSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ParametersTransmittedInSameOrder()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
var parameters = new object?[] { 42, "abc", null };
|
||||
|
||||
await repository.QueryAsync("SELECT * FROM T WHERE A = ? AND B = ? AND C = ?", parameters);
|
||||
|
||||
Assert.NotNull(connection.LastParameters);
|
||||
Assert.Equal(3, connection.LastParameters!.Count);
|
||||
Assert.Equal(42, connection.LastParameters[0]);
|
||||
Assert.Equal("abc", connection.LastParameters[1]);
|
||||
Assert.Null(connection.LastParameters[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_NullParametersTransmitted()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await repository.QueryAsync("SELECT 1", null);
|
||||
|
||||
Assert.Null(connection.LastParameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CancellationTokenTransmitted()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
var repository = new TestRepository(connection);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await repository.QueryAsync("SELECT 1", cancellationToken: cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, connection.LastCancellationToken);
|
||||
}
|
||||
|
||||
// --- QueryRowsAsync -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryRowsAsync_MapsSingleRow()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?> { ["ID"] = 7, ["NAME"] = "Alpha" }
|
||||
];
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
var rows = await repository.QueryRowsAsync<TestRow>("SELECT * FROM T");
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal(7, row.Id);
|
||||
Assert.Equal("Alpha", row.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryRowsAsync_MapsMultipleRows()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?> { ["ID"] = 1, ["NAME"] = "A" },
|
||||
new Dictionary<string, object?> { ["ID"] = 2, ["NAME"] = "B" },
|
||||
new Dictionary<string, object?> { ["ID"] = 3, ["NAME"] = "C" }
|
||||
];
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
var rows = await repository.QueryRowsAsync<TestRow>("SELECT * FROM T");
|
||||
|
||||
Assert.Equal(3, rows.Count);
|
||||
Assert.Equal(1, rows[0].Id);
|
||||
Assert.Equal("B", rows[1].Name);
|
||||
Assert.Equal(3, rows[2].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryRowsAsync_EmptyResult_ReturnsEmptyList()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.QueryResult = [];
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
var rows = await repository.QueryRowsAsync<TestRow>("SELECT * FROM T");
|
||||
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryRowsAsync_MappingError_Propagates()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
// Valeur incompatible avec la propriété int Id => HfsqlRowMapper lève.
|
||||
connection.QueryResult =
|
||||
[
|
||||
new Dictionary<string, object?> { ["ID"] = "pas-un-entier", ["NAME"] = "X" }
|
||||
];
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => repository.QueryRowsAsync<TestRow>("SELECT * FROM T"));
|
||||
}
|
||||
|
||||
// --- Scalar et NonQuery ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteScalarAsync_ReturnsScalar()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.ScalarResult = 123;
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
var result = await repository.ExecuteScalarAsync("SELECT COUNT(*) FROM T");
|
||||
|
||||
Assert.Equal(123, result);
|
||||
Assert.Equal("SELECT COUNT(*) FROM T", connection.LastSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteNonQueryAsync_ReturnsAffectedRows()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.NonQueryResult = 5;
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
var result = await repository.ExecuteNonQueryAsync("UPDATE T SET X = 1");
|
||||
|
||||
Assert.Equal(5, result);
|
||||
Assert.Equal("UPDATE T SET X = 1", connection.LastSql);
|
||||
}
|
||||
|
||||
// --- Propagation des erreurs infrastructure -------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_InfrastructureError_Propagates()
|
||||
{
|
||||
var connection = new FakeConnection(initialState: HfsqlConnectionState.Open);
|
||||
connection.ThrowOnQuery = true;
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.QueryAsync("SELECT 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpenError_Propagates()
|
||||
{
|
||||
var connection = new FakeConnection(throwOnOpen: true);
|
||||
var repository = new TestRepository(connection);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.QueryAsync("SELECT 1"));
|
||||
}
|
||||
|
||||
// --- Surface publique : aucune dépendance Batik/Omika/Client/ODBC ----------
|
||||
|
||||
[Fact]
|
||||
public void RepositoryBase_DoesNotReferenceBatikOmikaClientTypes()
|
||||
{
|
||||
var type = typeof(RepositoryBase);
|
||||
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
// Vérifie qu'aucun type métier Batik/Omika/Client n'apparaît dans la
|
||||
// surface publique/protégée. On teste le nom de type (et non le namespace
|
||||
// racine Omika.Data, qui contient légitimement IHfsqlConnection).
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Name.Contains("Batik", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.Name.Contains("Omika", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.Name.Contains("Client", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void RepositoryBase_DoesNotExposeOdbcTypes()
|
||||
{
|
||||
var type = typeof(RepositoryBase);
|
||||
|
||||
var referenced = GetPublicSurfaceTypes(type);
|
||||
|
||||
Assert.DoesNotContain(referenced, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetPublicSurfaceTypes(Type type)
|
||||
{
|
||||
var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
||||
.Select(m => m.DeclaringType)
|
||||
.Where(t => t is not null)
|
||||
.Cast<Type>();
|
||||
|
||||
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SelectMany(m => m.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(m => m.ReturnType);
|
||||
|
||||
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Select(p => p.PropertyType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties);
|
||||
}
|
||||
|
||||
// --- Row de test ----------------------------------------------------------
|
||||
|
||||
private sealed class TestRow
|
||||
{
|
||||
[HfSqlColumn("ID")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[HfSqlColumn("NAME")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
// --- Repository de test ---------------------------------------------------
|
||||
|
||||
private sealed class TestRepository : RepositoryBase
|
||||
{
|
||||
public TestRepository(IHfsqlConnection connection)
|
||||
: base(connection)
|
||||
{
|
||||
}
|
||||
|
||||
public new Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> base.QueryAsync(sql, parameters, cancellationToken);
|
||||
|
||||
public new Task<IReadOnlyList<TRow>> QueryRowsAsync<TRow>(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TRow : new()
|
||||
=> base.QueryRowsAsync<TRow>(sql, parameters, cancellationToken);
|
||||
|
||||
public new Task<object?> ExecuteScalarAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> base.ExecuteScalarAsync(sql, parameters, cancellationToken);
|
||||
|
||||
public new Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> base.ExecuteNonQueryAsync(sql, parameters, cancellationToken);
|
||||
}
|
||||
|
||||
// --- Fake de connexion ----------------------------------------------------
|
||||
|
||||
private sealed class FakeConnection : IHfsqlConnection
|
||||
{
|
||||
private readonly bool _throwOnOpen;
|
||||
|
||||
public FakeConnection(
|
||||
bool throwOnOpen = false,
|
||||
HfsqlConnectionState initialState = HfsqlConnectionState.Closed)
|
||||
{
|
||||
_throwOnOpen = throwOnOpen;
|
||||
State = initialState;
|
||||
}
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public int OpenCount { get; private set; }
|
||||
public int QueryCount { get; private set; }
|
||||
public string? LastSql { get; private set; }
|
||||
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> QueryResult { get; set; } = [];
|
||||
public object? ScalarResult { get; set; }
|
||||
public int NonQueryResult { get; set; }
|
||||
public bool ThrowOnQuery { get; set; }
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_throwOnOpen)
|
||||
throw new InvalidOperationException("Échec contrôlé de l'ouverture.");
|
||||
OpenCount++;
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
return Task.FromResult(ScalarResult);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
return Task.FromResult(NonQueryResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueryCount++;
|
||||
LastSql = sql;
|
||||
LastParameters = parameters;
|
||||
LastCancellationToken = cancellationToken;
|
||||
if (ThrowOnQuery)
|
||||
throw new InvalidOperationException("Échec contrôlé de la requête.");
|
||||
return Task.FromResult(QueryResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Technical;
|
||||
|
||||
|
||||
namespace Omika.Tests.Repositories.Technical;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires du suivi de statut, de la purge de MobLog et du routage des
|
||||
/// connexions du SyncTransferRepository.
|
||||
///
|
||||
/// Aucune base HFSQL réelle : les deux connexions physiques (Service pour TOSRV,
|
||||
/// WebService pour MobReception/MobLog) sont simulées par des fakes qui routent
|
||||
/// les requêtes vers des lignes en mémoire par table.
|
||||
/// </summary>
|
||||
public class SyncTransferRepositoryTests
|
||||
{
|
||||
private const string DeviceId = "device-1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// GetStatusAsync
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_OperationInTosrv_ReturnsPending()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
service.TosrvRows =
|
||||
[
|
||||
TosrvRow(operationId, DeviceId, retryCount: 2)
|
||||
];
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(operationId, DeviceId);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(SyncStatus.Pending, status.Status);
|
||||
Assert.Equal(2, status.RetryCount);
|
||||
Assert.Equal(operationId, status.OperationId);
|
||||
Assert.Equal(DeviceId, status.DeviceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_OperationInMobReception_ReturnsPending()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
web.ReceptionRows =
|
||||
[
|
||||
ReceptionRow(operationId, DeviceId, retryCount: 1)
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(operationId, DeviceId);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(SyncStatus.Pending, status.Status);
|
||||
Assert.Equal(1, status.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_SuccessInMobLog_ReturnsSuccessWithPublicGuids()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var requested = Guid.NewGuid();
|
||||
var canonical = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
web.LogRows =
|
||||
[
|
||||
LogRow(operationId, DeviceId, SyncStatus.Success,
|
||||
requestedEntityGuid: requested, canonicalEntityGuid: canonical)
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(operationId, DeviceId);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(SyncStatus.Success, status.Status);
|
||||
Assert.Equal(requested, status.RequestedEntityGuid);
|
||||
Assert.Equal(canonical, status.CanonicalEntityGuid);
|
||||
Assert.NotNull(status.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_RejectedInMobLog_ReturnsRejectedWithError()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
web.LogRows =
|
||||
[
|
||||
LogRow(operationId, DeviceId, SyncStatus.Rejected,
|
||||
errorCode: "MAX_RETRY", errorMessage: "Rejet définitif")
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(operationId, DeviceId);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(SyncStatus.Rejected, status.Status);
|
||||
Assert.Equal("MAX_RETRY", status.ErrorCode);
|
||||
Assert.Equal("Rejet définitif", status.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_Unknown_ReturnsNull()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(Guid.NewGuid(), DeviceId);
|
||||
|
||||
Assert.Null(status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatus_OtherDevice_ReturnsNull_NoLeak()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
// L'opération existe mais appartient à un autre appareil.
|
||||
web.LogRows =
|
||||
[
|
||||
LogRow(operationId, "autre-device", SyncStatus.Success)
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var status = await repo.GetStatusAsync(operationId, DeviceId);
|
||||
|
||||
// Aucune information métier sensible ne doit fuiter vers un autre device.
|
||||
Assert.Null(status);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PurgeLogsAsync
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_OldTerminalSyncLog_EmitsDeleteWithCutoff()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-31);
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.PurgeLogsAsync(cutoff);
|
||||
|
||||
var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog"));
|
||||
Assert.NotNull(delete);
|
||||
// Le cutoff est passé en paramètre.
|
||||
Assert.Equal(cutoff, delete.Parameters[0]);
|
||||
// Ciblage strict : uniquement les logs structurés Sync terminaux.
|
||||
// Les marqueurs sont passés en paramètres SQL (LIKE ?), pas littéralement
|
||||
// dans le SQL. On vérifie donc les valeurs des paramètres.
|
||||
Assert.Equal("%\"schemaVersion\"%", delete.Parameters[1]);
|
||||
Assert.Equal("%\"operationId\"%", delete.Parameters[2]);
|
||||
Assert.Equal($"%\"status\":\"{SyncStatus.Success}\"%", delete.Parameters[3]);
|
||||
Assert.Equal($"%\"status\":\"{SyncStatus.Rejected}\"%", delete.Parameters[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_RecentTerminalSyncLog_Kept()
|
||||
{
|
||||
// Un log récent ne doit pas être éligible : le cutoff est dans le futur
|
||||
// par rapport à la date du log. On vérifie que le SQL DELETE est bien
|
||||
// émis avec un cutoff qui exclut les logs récents (le filtrage par date
|
||||
// est appliqué côté base).
|
||||
var cutoff = DateTime.UtcNow.AddDays(-1);
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.PurgeLogsAsync(cutoff);
|
||||
|
||||
var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog"));
|
||||
Assert.Equal(cutoff, delete.Parameters[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_NonSyncLog_NeverDeleted()
|
||||
{
|
||||
// Le SQL de purge ne doit jamais cibler les logs non-Sync : il exige
|
||||
// la présence de "schemaVersion" et "operationId" (format structuré
|
||||
// propre au mécanisme de synchronisation mobile).
|
||||
var cutoff = DateTime.UtcNow.AddDays(-31);
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.PurgeLogsAsync(cutoff);
|
||||
|
||||
var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog"));
|
||||
// Les marqueurs "schemaVersion" et "operationId" sont passés en
|
||||
// paramètres SQL (LIKE ?), pas littéralement dans le SQL.
|
||||
Assert.Equal("%\"schemaVersion\"%", delete.Parameters[1]);
|
||||
Assert.Equal("%\"operationId\"%", delete.Parameters[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_PendingSyncLog_NeverDeleted()
|
||||
{
|
||||
// Le SQL de purge ne doit jamais cibler les opérations PENDING : il ne
|
||||
// filtre que sur les statuts terminaux SUCCESS / REJECTED.
|
||||
var cutoff = DateTime.UtcNow.AddDays(-31);
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.PurgeLogsAsync(cutoff);
|
||||
|
||||
var delete = web.Commands.Single(c => c.Sql.StartsWith("DELETE FROM MobLog"));
|
||||
// Les statuts sont passés en paramètres SQL (LIKE ?). On vérifie que
|
||||
// seuls SUCCESS et REJECTED sont ciblés, jamais PENDING.
|
||||
Assert.DoesNotContain(delete.Parameters, p => p is string s && s.Contains($"\"status\":\"{SyncStatus.Pending}\""));
|
||||
Assert.Equal($"%\"status\":\"{SyncStatus.Success}\"%", delete.Parameters[3]);
|
||||
Assert.Equal($"%\"status\":\"{SyncStatus.Rejected}\"%", delete.Parameters[4]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Routage des connexions
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task InsertToSrv_UsesServiceConnection_NotWebService()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.InsertToSrvAsync(new SyncEnvelope
|
||||
{
|
||||
OperationId = Guid.NewGuid(),
|
||||
DeviceId = DeviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = 0,
|
||||
Payload = "{}"
|
||||
});
|
||||
|
||||
// L'INSERT TOSRV doit être émis sur la connexion Service.
|
||||
Assert.Contains(service.Commands, c => c.Sql.StartsWith("INSERT INTO TOSRV"));
|
||||
// Aucune commande ne doit être émise sur la connexion WebService.
|
||||
Assert.Empty(web.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTerminalResult_UsesWebServiceConnection_NotService()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
web.LogRows =
|
||||
[
|
||||
LogRow(operationId, DeviceId, SyncStatus.Success)
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
var result = await repo.GetTerminalResultAsync(operationId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
// La lecture MobLog doit être émise sur la connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.Contains("FROM MobLog"));
|
||||
// Aucune commande ne doit être émise sur la connexion Service.
|
||||
Assert.Empty(service.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteLog_UsesWebServiceConnection_NotService()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.WriteLogAsync(new SyncLogEntry
|
||||
{
|
||||
OperationId = Guid.NewGuid(),
|
||||
Status = SyncStatus.Success,
|
||||
DeviceId = DeviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = 0,
|
||||
DateUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// L'INSERT MobLog doit être émis sur la connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog"));
|
||||
// Aucune commande ne doit être émise sur la connexion Service.
|
||||
Assert.Empty(service.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingOperations_UsesWebServiceConnection_NotService()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.GetPendingOperationsAsync();
|
||||
|
||||
// La lecture MobReception doit être émise sur la connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.Contains("FROM MobReception"));
|
||||
// Aucune commande ne doit être émise sur la connexion Service.
|
||||
Assert.Empty(service.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkSuccess_UsesWebServiceConnection_NotService()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.MarkSuccessAsync(new SyncLogEntry
|
||||
{
|
||||
OperationId = Guid.NewGuid(),
|
||||
Status = SyncStatus.Success,
|
||||
DeviceId = DeviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = 0,
|
||||
DateUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Le DELETE MobReception et l'INSERT MobLog doivent être émis sur la
|
||||
// connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("DELETE FROM MobReception"));
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog"));
|
||||
// Aucune commande ne doit être émise sur la connexion Service.
|
||||
Assert.Empty(service.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkRejected_UsesWebServiceConnection_NotService()
|
||||
{
|
||||
var service = new FakeServiceConnection();
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await repo.MarkRejectedAsync(new SyncLogEntry
|
||||
{
|
||||
OperationId = Guid.NewGuid(),
|
||||
Status = SyncStatus.Rejected,
|
||||
DeviceId = DeviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = 0,
|
||||
DateUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// L'UPDATE MobReception et l'INSERT MobLog doivent être émis sur la
|
||||
// connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("UPDATE MobReception"));
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobLog"));
|
||||
// Aucune commande ne doit être émise sur la connexion Service.
|
||||
Assert.Empty(service.Commands);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TransferToReceptionAsync (inter-connexion)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task TransferToReception_TosrvToMobReception_ThenDeleteTosrv()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
service.TosrvRows =
|
||||
[
|
||||
TosrvRow(operationId, DeviceId, retryCount: 0)
|
||||
];
|
||||
var web = new FakeWebServiceConnection();
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
int transferred = await repo.TransferToReceptionAsync();
|
||||
|
||||
Assert.Equal(1, transferred);
|
||||
// Lecture TOSRV sur la connexion Service.
|
||||
Assert.Contains(service.Commands, c => c.Sql.Contains("FROM TOSRV"));
|
||||
// Insertion MobReception sur la connexion WebService.
|
||||
Assert.Contains(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobReception"));
|
||||
// Suppression TOSRV sur la connexion Service (après succès de l'insertion).
|
||||
Assert.Contains(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferToReception_InsertionFails_TosrvNotLost()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
service.TosrvRows =
|
||||
[
|
||||
TosrvRow(operationId, DeviceId, retryCount: 0)
|
||||
];
|
||||
var web = new FakeWebServiceConnection();
|
||||
// L'insertion MobReception échoue.
|
||||
web.FailInsertMobReception = true;
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repo.TransferToReceptionAsync());
|
||||
|
||||
// TOSRV ne doit PAS être supprimé : l'opération n'est pas perdue.
|
||||
Assert.DoesNotContain(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransferToReception_AlreadyInMobReception_DoesNotDuplicate()
|
||||
{
|
||||
var operationId = Guid.NewGuid();
|
||||
var service = new FakeServiceConnection();
|
||||
service.TosrvRows =
|
||||
[
|
||||
TosrvRow(operationId, DeviceId, retryCount: 0)
|
||||
];
|
||||
var web = new FakeWebServiceConnection();
|
||||
// L'opération existe déjà dans MobReception (crash après insertion avant
|
||||
// suppression TOSRV). Un nouveau cycle ne doit pas recréer MobReception.
|
||||
web.ReceptionRows =
|
||||
[
|
||||
ReceptionRow(operationId, DeviceId, retryCount: 0)
|
||||
];
|
||||
var repo = new SyncTransferRepository(service, web);
|
||||
|
||||
int transferred = await repo.TransferToReceptionAsync();
|
||||
|
||||
// Aucune nouvelle insertion MobReception (idempotence par OperationId).
|
||||
Assert.DoesNotContain(web.Commands, c => c.Sql.StartsWith("INSERT INTO MobReception"));
|
||||
// TOSRV est nettoyé car l'opération est déjà connue dans MobReception.
|
||||
Assert.Contains(service.Commands, c => c.Sql.StartsWith("DELETE FROM TOSRV"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers de construction de lignes
|
||||
// =========================================================================
|
||||
|
||||
private static Dictionary<string, object?> TosrvRow(
|
||||
Guid operationId, string deviceId, int retryCount)
|
||||
{
|
||||
var envelope = new SyncEnvelope
|
||||
{
|
||||
OperationId = operationId,
|
||||
DeviceId = deviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = retryCount,
|
||||
Payload = "{}"
|
||||
};
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["IDTOSRV"] = 1,
|
||||
["TS_Date"] = DateTime.Now,
|
||||
["TS_Mobile"] = deviceId,
|
||||
["TS_CMD"] = "MOB.OP",
|
||||
["TS_Buffer"] = null,
|
||||
["TS_Lu"] = false,
|
||||
["TS_JSON"] = JsonSerializer.Serialize(envelope, JsonOptions)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ReceptionRow(
|
||||
Guid operationId, string deviceId, int retryCount)
|
||||
{
|
||||
var envelope = new SyncEnvelope
|
||||
{
|
||||
OperationId = operationId,
|
||||
DeviceId = deviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RetryCount = retryCount,
|
||||
Payload = "{}"
|
||||
};
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["MR_PKID"] = 1L,
|
||||
["MR_IDToSRV"] = 1L,
|
||||
["MR_JSON"] = JsonSerializer.Serialize(envelope, JsonOptions),
|
||||
["MR_CMD"] = "MOB.OP",
|
||||
["MR_Buffer"] = null,
|
||||
["MR_NumSerie"] = null,
|
||||
["MR_Fait"] = false,
|
||||
["MR_DateHeureRecep"] = DateTime.Now
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> LogRow(
|
||||
Guid operationId,
|
||||
string deviceId,
|
||||
SyncStatus status,
|
||||
Guid? requestedEntityGuid = null,
|
||||
Guid? canonicalEntityGuid = null,
|
||||
string? errorCode = null,
|
||||
string? errorMessage = null)
|
||||
{
|
||||
var entry = new SyncLogEntry
|
||||
{
|
||||
OperationId = operationId,
|
||||
Status = status,
|
||||
DeviceId = deviceId,
|
||||
EntityType = "Client",
|
||||
OperationType = SyncOperationType.Create,
|
||||
EntityGuid = Guid.NewGuid(),
|
||||
RequestedEntityGuid = requestedEntityGuid,
|
||||
CanonicalEntityGuid = canonicalEntityGuid,
|
||||
RetryCount = 0,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage,
|
||||
DateUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var message = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["schemaVersion"] = 1,
|
||||
["operationId"] = operationId,
|
||||
["status"] = status.ToString(),
|
||||
["deviceId"] = deviceId,
|
||||
["entityType"] = "Client",
|
||||
["operationType"] = "Create",
|
||||
["entityGuid"] = entry.EntityGuid,
|
||||
["requestedEntityGuid"] = requestedEntityGuid,
|
||||
["canonicalEntityGuid"] = canonicalEntityGuid,
|
||||
["retryCount"] = 0,
|
||||
["errorCode"] = errorCode,
|
||||
["errorMessage"] = errorMessage,
|
||||
["completedAtUtc"] = DateTime.UtcNow
|
||||
}, JsonOptions);
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["ML_PKID"] = 1L,
|
||||
["ML_DateCreat"] = DateTime.Now,
|
||||
["ML_Message"] = message,
|
||||
["ML_Niveau"] = (sbyte)0
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fakes de connexion (Service et WebService)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Fake de la connexion Service : route les requêtes TOSRV vers des lignes
|
||||
/// en mémoire et enregistre les commandes exécutées.
|
||||
/// </summary>
|
||||
private sealed class FakeServiceConnection : IServiceHfSqlConnection
|
||||
{
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> TosrvRows { get; set; } = [];
|
||||
public List<ExecutedCommand> Commands { get; } = [];
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = 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 ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
if (ContainsFrom(sql, "TOSRV")) return Task.FromResult(TosrvRows);
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake de la connexion WebService/Business : route les requêtes MobReception
|
||||
/// et MobLog vers des lignes en mémoire et enregistre les commandes exécutées.
|
||||
/// </summary>
|
||||
private sealed class FakeWebServiceConnection : IWebServiceHfSqlConnection
|
||||
{
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> LogRows { get; set; } = [];
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ReceptionRows { get; set; } = [];
|
||||
public bool FailInsertMobReception { get; set; }
|
||||
public List<ExecutedCommand> Commands { get; } = [];
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = 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 ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
if (FailInsertMobReception && sql.StartsWith("INSERT INTO MobReception"))
|
||||
{
|
||||
throw new InvalidOperationException("Échec simulé de l'insertion MobReception.");
|
||||
}
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql,
|
||||
IReadOnlyList<object?>? parameters = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Commands.Add(new ExecutedCommand(sql, parameters ?? []));
|
||||
if (ContainsFrom(sql, "MobLog")) return Task.FromResult(LogRows);
|
||||
if (ContainsFrom(sql, "MobReception")) return Task.FromResult(ReceptionRows);
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
||||
string tableName,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
|
||||
}
|
||||
Reference in New Issue
Block a user