Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,530 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Models.Batik;
using Omika.Data.Repositories.Batik;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests de la stratégie BULK bornée de
/// <see cref="BatikInterventionRepository.SearchDetailsAsync"/>.
///
/// Ces tests garantissent que le bulk des interventions :
/// <list type="bullet">
/// <item>n'appelle JAMAIS GetDetailsAsync par intervention (anti-N+1) ;</item>
/// <item>charge chaque table relationnelle en UNE SEULE requête HFSQL (colonnes
/// ciblées) puis filtre en mémoire via un HashSet des identifiants de la page ;
/// le nombre de requêtes relationnelles est borné (1 par table), indépendant du
/// nombre d'interventions ;</item>
/// <item>n'utilise aucun WHERE IN massif ni aucun batching Chunk.</item>
/// </list>
///
/// Le test comportemental <see cref="SearchDetailsAsync_200Interventions_RelationsBulkBornees"/>
/// exécute réellement le repository sur un <see cref="SpyingHfsqlConnection"/>
/// (fake mémoire, aucun HFSQL réel) et vérifie le comportement observé :
/// nombre de requêtes borné (1 par table), absence de GetById individuel,
/// filtrage correct des lignes hors page, assemblage correct des relations.
/// Il ne repose sur aucune recherche de texte source.
/// </summary>
public class BatikInterventionBulkStrategyTests
{
private static Type RepositoryType => typeof(BatikInterventionRepository);
[Fact]
public void SearchDetailsAsync_DoesNotCallGetDetailsAsync()
{
// Le bulk SearchDetailsAsync doit assembler les relations en mémoire et
// ne JAMAIS déléguer à GetDetailsAsync par intervention (N+1).
MethodInfo? searchDetails = RepositoryType.GetMethod(
"SearchDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(searchDetails);
// Vérifie que le corps de SearchDetailsAsync ne contient pas d'appel à
// GetDetailsAsync. On inspecte le code IL : aucun appel à la méthode
// GetDetailsAsync ne doit être présent.
MethodBody? body = searchDetails.GetMethodBody();
Assert.NotNull(body);
byte[]? il = body.GetILAsByteArray();
Assert.NotNull(il);
byte[] ilBytes = il;
// Recherche de la signature de GetDetailsAsync dans le même type.
MethodInfo? getDetails = RepositoryType.GetMethod(
"GetDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(getDetails);
// Le token de la méthode GetDetailsAsync ne doit pas apparaître dans le
// corps IL de SearchDetailsAsync.
int token = getDetails.MetadataToken;
byte[] tokenBytes = BitConverter.GetBytes(token);
Assert.DoesNotContain(ilBytes, b => b == tokenBytes[0] && ContainsToken(ilBytes, tokenBytes));
}
[Fact]
public void BulkRelationLoaders_Exist()
{
// Les trois chargeurs relationnels BULK doivent exister.
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionPersonnesBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionCollaborateursBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadEtatsInterventionBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
}
[Fact]
public void BulkRelationLoaders_UseTargetedColumnSelection()
{
// Chaque chargeur relationnel BULK doit lire des colonnes ciblées (et
// non SELECT *) : les constantes SQL des colonnes doivent exister.
// Combiné au test comportemental SearchDetailsAsync_200Interventions_RelationsBulkBornees,
// cela garantit une lecture relationnelle ciblée en une seule requête,
// jamais un full scan de table entière ni un SELECT *.
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionPersonneColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionCollaborateurColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectEtatInterventionColumns",
BindingFlags.NonPublic | BindingFlags.Static));
}
[Fact]
public async Task SearchDetailsAsync_200Interventions_RelationsBulkBornees()
{
// Test COMPORTEMENTAL : exécute réellement SearchDetailsAsync sur un
// fake mémoire (SpyingHfsqlConnection) qui enregistre chaque requête.
// Aucun HFSQL réel, aucun ODBC, aucun réseau.
//
// Scénario : 200 interventions, chacune avec 1 personne et 1 état. Les
// tables relationnelles lues dans ce chemin (Intervention_Personne,
// EtatIntervention) doivent être chargées en UNE SEULE requête (lecture
// bulk ciblée), puis filtrées en mémoire via un HashSet. Le nombre de
// requêtes relationnelles doit être borné à 1 par table, soit 2 requêtes
// relationnelles au total — jamais 200 par table (N+1), jamais 4 par
// table (batching Chunk).
//
// On ajoute aussi des lignes PARASITES (interventions hors page, IDs
// 1000+) dans chaque table relationnelle pour vérifier que le filtre
// HashSet les exclut correctement : la lecture globale ne doit pas
// mélanger les relations d'autres interventions.
const int interventionCount = 200;
const int parasiteCount = 5;
var connection = new SpyingHfsqlConnection();
connection.InterventionRows = BuildInterventionRows(interventionCount);
connection.PersonneRows = BuildPersonneRows(interventionCount, parasiteCount);
connection.CollaborateurRows = BuildCollaborateurRows(interventionCount, parasiteCount);
connection.EtatRows = BuildEtatRows(interventionCount, parasiteCount);
var repository = new BatikInterventionRepository(connection);
PagedResult<Intervention> result = await repository.SearchDetailsAsync(
new InterventionSearchCriteria { Page = 1, PageSize = interventionCount });
// 1. Toutes les interventions sont retournées.
Assert.Equal(interventionCount, result.Items.Count);
// 2. Nombre de requêtes relationnelles borné : EXACTEMENT 1 par table.
// Aucune requête par intervention (N+1) : sinon on aurait 200 requêtes
// par table. Aucun batching Chunk : sinon on aurait 4 requêtes par
// table.
int personneQueries = connection.CountQueriesContaining("FROM Intervention_Personne");
int collaborateurQueries = connection.CountQueriesContaining("FROM Intervention_Collaborateur");
int etatQueries = connection.CountQueriesContaining("FROM EtatIntervention");
Assert.Equal(1, personneQueries);
// SearchDetailsAsync charge en bulk les personnes et les états. La table
// Intervention_Collaborateur n'est pas lue dans ce chemin : les
// collaborateurs sont résolus par la couche service (MobileIdentityMapper)
// à partir de la référence source, pas par un bulk relationnel ici.
Assert.Equal(0, collaborateurQueries);
Assert.Equal(1, etatQueries);
// 3. Aucun GetByIdAsync individuel : aucune requête ne cible une seule
// intervention par IN_PKID = ? (pattern du détail).
Assert.Equal(0, connection.CountQueriesContaining("WHERE IN_PKID = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IP_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IC_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE EI_IDIntervention = ?"));
// 4. Aucun WHERE IN massif dans les lectures relationnelles : les tables
// relationnelles sont lues globalement (sans clause IN) puis filtrées
// en mémoire via un HashSet. L'enrichissement client/site/adresse
// utilise une jointure unique avec un IN paramétré borné à la page,
// ce qui n'est pas une lecture relationnelle.
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Personne", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM EtatIntervention", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Collaborateur", "IN ("));
// 5. Les relations sont assemblées correctement : chaque intervention
// porte exactement 1 action (état) rattachée. Les lignes parasites
// (interventions hors page) ne doivent pas être rattachées.
foreach (Intervention intervention in result.Items)
{
Assert.Single(intervention.Actions);
}
}
// --- Helpers --------------------------------------------------------------
private static List<Dictionary<string, object?>> BuildInterventionRows(int count)
{
var rows = new List<Dictionary<string, object?>>(count);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IN_PKID"] = (long)i,
["IN_IDTypeIntervention"] = 0L,
["IN_IDEtatIntervention"] = 0L,
["IN_IDSuiviPar"] = 0L,
["IN_IDDossier"] = 0L,
["IN_IDDemandeur"] = 0L,
["IN_IDClient"] = 0L,
["IN_IDLieu"] = 0L,
["IN_Description"] = null,
["IN_AttestationTVAPresente"] = false,
["IN_NoteInterne"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L,
["IN_Titre"] = $"Intervention {i}",
["IN_RefInterne"] = $"INT-{i:D4}",
["IN_RefExterne"] = null,
["IN_IDImport"] = null,
["IN_IDSociete"] = 0L,
["Archive"] = false,
["DateArchivage"] = DateTime.MinValue,
["IN_DateIntervention"] = new DateOnly(2026, 1, 1),
["IN_DureePrevue"] = 0f,
["IN_NumDevis"] = null,
["IN_DateDevis"] = default(DateOnly),
["IN_NumFacture"] = null,
["IN_DateFacture"] = default(DateOnly),
["IN_NumIntervention"] = null
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildPersonneRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = (long)i,
["IP_IDIntervention"] = (long)i,
["IP_IDPersonne"] = (long)i,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+). Elles doivent
// être exclues par le filtre HashSet du repository.
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = id,
["IP_IDIntervention"] = id,
["IP_IDPersonne"] = id,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildCollaborateurRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = (long)i,
["IC_IDIntervention"] = (long)i,
["IC_IDCollaborateur"] = (long)i,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = id,
["IC_IDIntervention"] = id,
["IC_IDCollaborateur"] = id,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildEtatRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = (long)i,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État {i}",
["EI_IDIntervention"] = (long)i
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = id,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État parasite {p}",
["EI_IDIntervention"] = id
});
}
return rows;
}
private static bool ContainsToken(byte[] il, byte[] tokenBytes)
{
for (int i = 0; i <= il.Length - tokenBytes.Length; i++)
{
bool match = true;
for (int j = 0; j < tokenBytes.Length; j++)
{
if (il[i + j] != tokenBytes[j])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
// =========================================================================
// SpyingHfsqlConnection
// =========================================================================
// Fake mémoire de IHfsqlConnection qui enregistre chaque requête SQL et
// retourne des lignes par table. Aucun HFSQL réel, aucun ODBC, aucun réseau.
// Permet de compter les requêtes et de vérifier le comportement bulk réel.
// =========================================================================
private sealed class SpyingHfsqlConnection : IHfsqlConnection
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
public int QueryCount { get; private set; }
public IReadOnlyList<string> AllSql => _allSql;
private readonly List<string> _allSql = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> InterventionRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PersonneRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> EtatRows { get; set; } = [];
public int CountQueriesContaining(params string[] fragments)
=> _allSql.Count(sql => fragments.All(f => sql.Contains(f, StringComparison.OrdinalIgnoreCase)));
private void Record(string sql)
{
QueryCount++;
_allSql.Add(sql);
}
public Task OpenAsync(CancellationToken cancellationToken = default)
{
State = HfsqlConnectionState.Open;
return Task.CompletedTask;
}
public Task CloseAsync()
{
State = HfsqlConnectionState.Closed;
return Task.CompletedTask;
}
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
if (sql.Contains("COUNT(*)", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<object?>((long)InterventionRows.Count);
}
return Task.FromResult<object?>(null);
}
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(0);
}
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(ResolveRows(sql, parameters));
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
string tableName, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
}
public ValueTask DisposeAsync()
{
State = HfsqlConnectionState.Closed;
return ValueTask.CompletedTask;
}
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
string sql, IReadOnlyList<object?>? parameters)
{
// Les requêtes relationnelles sont des lectures globales (SELECT ...
// FROM table, sans WHERE IN) : le repository filtre ensuite en mémoire
// via un HashSet des identifiants de la page. Le fake retourne donc
// TOUTES les lignes (y compris les parasites hors page) pour que le
// filtre mémoire du repository soit réellement exercé.
if (ContainsFrom(sql, "Intervention_Personne"))
{
return FilterByInterventionId(PersonneRows, parameters, "IP_IDIntervention");
}
if (ContainsFrom(sql, "Intervention_Collaborateur"))
{
return FilterByInterventionId(CollaborateurRows, parameters, "IC_IDIntervention");
}
if (ContainsFrom(sql, "EtatIntervention"))
{
return FilterByInterventionId(EtatRows, parameters, "EI_IDIntervention");
}
if (ContainsFrom(sql, "Intervention"))
{
return InterventionRows;
}
return [];
}
private static IReadOnlyList<IReadOnlyDictionary<string, object?>> FilterByInterventionId(
IReadOnlyList<IReadOnlyDictionary<string, object?>> rows,
IReadOnlyList<object?>? parameters,
string idColumn)
{
if (parameters is null || parameters.Count == 0)
{
return rows;
}
var ids = new HashSet<long>();
foreach (object? p in parameters)
{
if (p is long l)
{
ids.Add(l);
}
else if (p is int i)
{
ids.Add(i);
}
}
if (ids.Count == 0)
{
return rows;
}
return rows
.Where(r => r.TryGetValue(idColumn, out object? v) && v is long id && ids.Contains(id))
.ToList();
}
private static bool ContainsFrom(string sql, string table)
{
string marker = $"FROM {table}";
int index = sql.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
int next = index + marker.Length;
if (next >= sql.Length || (!char.IsLetterOrDigit(sql[next]) && sql[next] != '_'))
{
return true;
}
index = sql.IndexOf(marker, index + 1, StringComparison.OrdinalIgnoreCase);
}
return false;
}
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
using Omika.Core.Observability;
namespace Omika.Tests.Architecture;
/// <summary>
/// Règle d'architecture (Phase 1) : le métier ne doit référencer aucune pile
/// d'export d'observabilité (OpenTelemetry, SigNoz, ClickHouse, OTLP). Il ne
/// connaît que les abstractions <c>IOmikaTelemetry</c> / <c>IEventTracker</c> /
/// <c>IDiagnosticLogger</c>. OpenTelemetry n'étant pas installé, ce test prépare
/// la règle pour les phases suivantes.
/// </summary>
public class BusinessMustNotReferenceObservabilityExportTests
{
[Fact]
public void OmikaCore_DoesNotReference_OpenTelemetryOrSigNozOrClickHouseOrOtlp()
{
Assembly core = typeof(IOmikaTelemetry).Assembly;
string[] referenced = core.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(referenced, n => n.StartsWith("OpenTelemetry", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("SigNoz", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("ClickHouse", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.Contains("OTLP", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,21 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer ASP.NET Core : les contrats sont de purs
/// DTO (pagination incluse) sans dépendance framework web.
/// </summary>
public class CoreMustNotReferenceAspNetCoreTests
{
[Fact]
public void OmikaCore_DoesNotReference_AspNetCore()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.Extensions.Primitives", StringComparison.Ordinal));
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Avalonia : le domaine et les contrats sont
/// des bibliothèques de classes pures.
/// </summary>
public class CoreMustNotReferenceAvaloniaTests
{
[Fact]
public void OmikaCore_DoesNotReference_Avalonia()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] avaloniaNames =
[
"Avalonia",
"Avalonia.Base",
"Avalonia.Controls",
"Avalonia.Desktop",
"Avalonia.Themes.Fluent"
];
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => avaloniaNames.Contains(name));
}
}
@@ -0,0 +1,45 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Omika.Data : le modèle métier canonique et
/// les contrats API sont indépendants de la persistance HFSQL.
/// </summary>
public class CoreMustNotReferenceDataTests
{
[Fact]
public void OmikaCore_DoesNotReference_OmikaData()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
Assert.DoesNotContain(
core.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void OmikaCore_ContainsNoHfSqlAttribute()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
// Les attributs HFSQL appartiennent à Omika.Data : aucun type d'Omika.Core
// ne doit en porter.
var hfSqlAttributeNames = new[]
{
"HfSqlTableAttribute",
"HfSqlColumnAttribute",
"HfSqlPrimaryKeyAttribute"
};
foreach (Type type in core.GetTypes())
{
foreach (string attributeName in hfSqlAttributeNames)
{
Assert.DoesNotContain(
type.GetCustomAttributesData(),
attr => attr.AttributeType.Name == attributeName);
}
}
}
}
@@ -0,0 +1,165 @@
using System.Reflection;
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Interfaces;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests du contrat canonique <see cref="IClientRepository"/> : indépendance
/// vis-à-vis d'Omika.Data, de HFSQL/ODBC et des modèles physiques, périmètre
/// lecture seule et compatibilité avec une implémentation en mémoire.
/// </summary>
public class IClientRepositoryContractTests
{
private static Type RepositoryType => typeof(IClientRepository);
private static Assembly CoreAssembly => typeof(IClientRepository).Assembly;
[Fact]
public void Interface_ExistsInOmikaCore()
{
Assert.True(typeof(IClientRepository).IsInterface);
Assert.Equal("Omika.Core", CoreAssembly.GetName().Name);
}
[Fact]
public void Core_DoesNotReferenceOmikaData()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void Core_DoesNotReferenceOdbcOrHfSql()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "System.Data.Odbc");
}
[Fact]
public void Signatures_DoNotExposePhysicalRowTypes()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.False(
method.ReturnType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le retour de « {method.Name} » expose un type physique « {method.ReturnType.Name} ».");
foreach (ParameterInfo parameter in method.GetParameters())
{
Assert.False(
parameter.ParameterType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le paramètre « {parameter.Name} » de « {method.Name} » expose un type physique « {parameter.ParameterType.Name} ».");
}
}
}
[Fact]
public void HasSearchMethod_ReturningCanonicalPagedResult()
{
MethodInfo? search = RepositoryType.GetMethod("SearchAsync");
Assert.NotNull(search);
Assert.Equal(typeof(Task<PagedResult<Client>>), search.ReturnType);
}
[Fact]
public void HasDetailsMethod_ReturningNullableClient()
{
MethodInfo? details = RepositoryType.GetMethod("GetDetailsAsync");
Assert.NotNull(details);
Assert.Equal(typeof(Task<Client>), details.ReturnType);
}
[Fact]
public void AllMethods_AcceptCancellationToken()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.Contains(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(CancellationToken));
}
}
[Fact]
public void HasNoDeleteMethods()
{
// CreateAsync, UpdateAsync et ArchiveAsync existent maintenant (écriture
// supportée). Seule la suppression physique (Delete) reste interdite.
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Delete", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void HasNoBatikOrOmikaSpecificMethods()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Batik", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Omika", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Legacy", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void CanBeImplementedByInMemoryFake()
{
var fake = new InMemoryClientRepository();
PagedResult<Client> result = fake.SearchAsync(new ClientSearchCriteria()).GetAwaiter().GetResult();
Client? missing = fake.GetDetailsAsync("CL-001").GetAwaiter().GetResult();
Assert.Empty(result.Items);
Assert.Null(missing);
}
/// <summary>
/// Implémentation en mémoire minimale prouvant que le contrat est
/// implémentable sans aucune dépendance physique.
/// </summary>
private sealed class InMemoryClientRepository : IClientRepository
{
public Task<Client> CreateAsync(
ClientCreateCommand command,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(command);
return Task.FromResult(new Client { Nom = command.Nom });
}
public Task<PagedResult<Client>> SearchAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<PagedResult<Client>> SearchDetailsAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<Client?> GetDetailsAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<Client?> UpdateAsync(
string id, Omika.Core.Domain.Commands.ClientUpdateCommand command,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<bool> ArchiveAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit contenir de nom de rubrique HFSQL, de préfixe
/// physique (CL_, IN_, PL_, RDV_, DC_…) ni de nom de table de base.
/// </summary>
public class PublicContractsMustNotContainHfSqlNamesTests
{
private static readonly string[] ForbiddenFragments =
[
"CL_", "IN_", "PL_", "RDV_", "AD_Ligne", "DC_",
"PKID", "IDType", "CleType", "IDStatut", "CleStatut",
"RDP_GUID", "PL_PKID", "RDP_", "HfSql", "HFSQL"
];
private static readonly string[] ForbiddenTableNames =
[
"Planning_RDV", "RDV_Planning", "RDV_Personne", "RDV_Collaborateur",
"RDV_Contact", "AccesEtDroit", "CategorieAcces", "RubriquesDocument"
];
/// <summary>
/// Noms interdits en tant que nom de propriété complet (et non sous-chaîne,
/// pour ne pas rejeter des mots métier français comme « Comptable »).
/// </summary>
private static readonly string[] ForbiddenExactPropertyNames =
[
"Table", "Row"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true)
.Where(t => !t.IsGenericTypeDefinition || t.Name.Contains("PagedResult", StringComparison.Ordinal));
private static IEnumerable<string> GetPublicPropertyNames(Type type)
=> type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name);
[Fact]
public void NoContractType_HasPhysicalTableOrPrefixInItsName()
{
foreach (Type type in GetContractTypes())
{
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
type.Name.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le type de contrat « {type.FullName} » contient la référence physique « {forbidden} ».");
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalPrefixOrRubricName()
{
foreach (Type type in GetContractTypes())
{
foreach (string propertyName in GetPublicPropertyNames(type))
{
// Vérification des fragments de rubrique / noms de table.
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
propertyName.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{propertyName} » contient la référence physique « {forbidden} ».");
}
// Vérification des noms interdits uniquement en nom complet,
// afin d'éviter les faux positifs (« Comptable » contient « Table »).
Assert.DoesNotContain(
ForbiddenExactPropertyNames,
name => string.Equals(name, propertyName, StringComparison.OrdinalIgnoreCase));
}
}
}
[Fact]
public void Contracts_DoNotExposeOmikaDataTypes()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string propertyTypeName = property.PropertyType.Name;
Assert.False(
propertyTypeName.Contains("Row", StringComparison.OrdinalIgnoreCase)
&& propertyTypeName.EndsWith("Row", StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose un type physique « {propertyTypeName} ».");
}
}
}
}
@@ -0,0 +1,61 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit exposer de « champ zombie » : les rubriques
/// physiques obsolètes (anciens identifiants de civilité ou de statut, clés
/// internes, rubriques marquées legacy) sont converties par le mapper et ne
/// doivent jamais transiter telles quelles vers l'application.
/// </summary>
public class PublicContractsMustNotContainZombieFieldsTests
{
private static readonly string[] ZombieMarkers =
[
"Zombie", "LegacyField", "IsLegacy", "Ancien", "OldId",
"IdCivilite", "IdStatut", "CleStatut", "CleType", "IDType",
"PKID", "RDP_GUID", "PL_PKID", "GUIDPhysique", "PhysicalGuid",
"NumeroCle", "CleNumero"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true);
[Fact]
public void NoContractProperty_HasZombieMarker()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
foreach (string marker in ZombieMarkers)
{
Assert.False(
property.Name.Contains(marker, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose le champ zombie « {marker} ».");
}
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalRowShapedTypes()
{
// Les DTO ne doivent pas exposer de propriété dont le type est une classe
// physique (Row) d'Omika.Data : les contrats sont autonomes.
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string ns = property.PropertyType.Namespace ?? string.Empty;
Assert.False(
ns.StartsWith("Omika.Data", StringComparison.Ordinal),
$"Le contrat « {type.FullName}.{property.Name} » référence un type d'Omika.Data.");
}
}
}
}