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