Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
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 &gt; 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);
}