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; /// /// Tests de la stratégie BULK bornée de /// . /// /// Ces tests garantissent que le bulk des interventions : /// /// n'appelle JAMAIS GetDetailsAsync par intervention (anti-N+1) ; /// 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 ; /// n'utilise aucun WHERE IN massif ni aucun batching Chunk. /// /// /// Le test comportemental /// exécute réellement le repository sur un /// (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. /// 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 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> BuildInterventionRows(int count) { var rows = new List>(count); for (int i = 1; i <= count; i++) { rows.Add(new Dictionary { ["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> BuildPersonneRows(int count, int parasiteCount = 0) { var rows = new List>(count + parasiteCount); for (int i = 1; i <= count; i++) { rows.Add(new Dictionary { ["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 { ["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> BuildCollaborateurRows(int count, int parasiteCount = 0) { var rows = new List>(count + parasiteCount); for (int i = 1; i <= count; i++) { rows.Add(new Dictionary { ["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 { ["IC_PKID"] = id, ["IC_IDIntervention"] = id, ["IC_IDCollaborateur"] = id, ["CreationDate"] = DateTime.MinValue, ["CreationUser"] = 0L, ["ModifDate"] = DateTime.MinValue, ["ModifUser"] = 0L }); } return rows; } private static List> BuildEtatRows(int count, int parasiteCount = 0) { var rows = new List>(count + parasiteCount); for (int i = 1; i <= count; i++) { rows.Add(new Dictionary { ["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 { ["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 AllSql => _allSql; private readonly List _allSql = []; public IReadOnlyList> InterventionRows { get; set; } = []; public IReadOnlyList> PersonneRows { get; set; } = []; public IReadOnlyList> CollaborateurRows { get; set; } = []; public IReadOnlyList> 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 ExecuteScalarAsync(string sql, IReadOnlyList? parameters = null, CancellationToken cancellationToken = default) { Record(sql); if (sql.Contains("COUNT(*)", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult((long)InterventionRows.Count); } return Task.FromResult(null); } public Task ExecuteNonQueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken cancellationToken = default) { Record(sql); return Task.FromResult(0); } public Task>> QueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken cancellationToken = default) { Record(sql); return Task.FromResult(ResolveRows(sql, parameters)); } public Task> GetColumnsAsync( string tableName, CancellationToken cancellationToken = default) { return Task.FromResult>([]); } public ValueTask DisposeAsync() { State = HfsqlConnectionState.Closed; return ValueTask.CompletedTask; } private IReadOnlyList> ResolveRows( string sql, IReadOnlyList? 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> FilterByInterventionId( IReadOnlyList> rows, IReadOnlyList? parameters, string idColumn) { if (parameters is null || parameters.Count == 0) { return rows; } var ids = new HashSet(); 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; } } }