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