Files
Omika/tests/Omika.Tests/Architecture/BatikInterventionBulkStrategyTests.cs
T
2026-10-23 18:46:50 +02:00

531 lines
21 KiB
C#

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;
}
}
}