using Omika.Core.Domain; using Omika.Core.Domain.Commands; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.HfSql.Metadata; using Omika.Data.Repositories.Batik; namespace Omika.Tests.Repositories.Batik; /// /// Tests unitaires de l'annulation d'un rendez-vous Batik /// () : aucun serveur HFSQL, /// aucun ODBC réel, aucun réseau. Un fake enregistre /// le SQL et les paramètres et retourne des dictionnaires selon la table interrogée. /// /// Objectifs : /// /// un rendez-vous INDIVIDUEL (PL_IDGroupe = 0) n'annule que sa propre /// ligne Planning_RDV (ciblée par PL_PKID) ; /// un rendez-vous GROUPÉ (PL_IDGroupe > 0) annule TOUTES les lignes /// du groupe (ciblées par PL_IDGroupe) : tous les rendez-vous du groupe sont /// impactés ; /// la description reçoit la mention d'annulation /// Note (Nom) : message, le nom étant résolu côté serveur s'il n'est pas /// fourni. /// /// public class BatikRendezVousAnnulationTests { private const long RendezVousId = 42L; private const string DescriptionInitiale = "Description initiale"; [Fact] public async Task CancelAsync_RdvIndividuel_CibleUniquementSaPropreLigne() { var connection = new FakeHfsqlConnection { PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)], ParametreMetierRows = ReferenceRows() }; var repository = new BatikRendezVousRepository(connection); bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand { OrigineKey = "O1", MotifKey = "M1", Commentaire = "Client absent", CollaborateurNom = "Jean DUPONT" }); Assert.True(cancelled); ExecutedCommand write = Assert.Single(connection.Writes); Assert.Contains("UPDATE Planning_RDV SET", write.Sql); // Aucun groupe : la ligne est ciblée par sa clé primaire. Assert.Contains("WHERE PL_PKID = ?", write.Sql); Assert.DoesNotContain("PL_IDGroupe", write.Sql); // Statut « Annulé » (ParametreMetier PA_PKID=1000) + origine + motif. Assert.Equal(1000L, write.Parameters[2]); Assert.Equal(2000L, write.Parameters[0]); Assert.Equal(3000L, write.Parameters[1]); Assert.Equal( DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent", write.Parameters[3] as string); // L'annulation modifie la ligne : elle porte l'estampille d'audit. Sans // auteur résolvable, ModifDate seul est écrit (jamais ModifUser = 0). Assert.Contains("ModifDate = ?", write.Sql); Assert.DoesNotContain("ModifUser", write.Sql); Assert.IsType(write.Parameters[4]); Assert.Equal(RendezVousId, write.Parameters[5]); AssertParametersMatchPlaceholders(write); } [Fact] public async Task CancelAsync_RdvGroupe_AnnuleToutesLesLignesDuGroupe() { var connection = new FakeHfsqlConnection { // Le rendez-vous appartient au groupe 99 : plusieurs lignes Planning_RDV // partagent ce PL_IDGroupe (une par collaborateur). PlanningRows = [PlanningRow(RendezVousId, idGroupe: 99L, collaborateurId: 7L)], ParametreMetierRows = ReferenceRows() }; var repository = new BatikRendezVousRepository(connection); bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand { OrigineKey = "O1", MotifKey = "M1", Commentaire = "Client absent", CollaborateurNom = "Jean DUPONT" }); Assert.True(cancelled); ExecutedCommand write = Assert.Single(connection.Writes); // Le groupe entier est annulé : ciblage par PL_IDGroupe, jamais par PL_PKID. Assert.Contains("WHERE PL_IDGroupe = ?", write.Sql); Assert.Equal( DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent", write.Parameters[3] as string); // Estampille d'audit (ModifDate) puis la clé du groupe. Assert.Contains("ModifDate = ?", write.Sql); Assert.Equal(99L, write.Parameters[5]); AssertParametersMatchPlaceholders(write); } [Fact] public async Task CancelAsync_AvecAuteur_EstampilleModifDateEtModifUser() { var connection = new FakeHfsqlConnection { PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)], ParametreMetierRows = ReferenceRows() }; var repository = new BatikRendezVousRepository(connection); bool cancelled = await repository.CancelAsync( "42", new AnnulationRendezVousCommand { OrigineKey = "O1", MotifKey = "M1", Commentaire = "Client absent", CollaborateurNom = "Jean DUPONT" }, auteur: new AuditActor("77", null)); Assert.True(cancelled); ExecutedCommand write = Assert.Single(connection.Writes); Assert.Contains("ModifDate = ?", write.Sql); Assert.Contains("ModifUser = ?", write.Sql); Assert.IsType(write.Parameters[4]); Assert.Equal(77L, write.Parameters[5]); Assert.Equal(RendezVousId, write.Parameters[6]); AssertParametersMatchPlaceholders(write); } [Fact] public async Task CancelAsync_SansNomCollaborateur_ResoutLeNomDepuisLeCollaborateurAffecte() { var connection = new FakeHfsqlConnection { PlanningRows = [PlanningRow(RendezVousId, idGroupe: 0L, collaborateurId: 7L)], ParametreMetierRows = ReferenceRows(), CollaborateurRows = [ new Dictionary { ["CO_PKID"] = 7L, ["CO_Nom"] = "DUPONT", ["CO_Prenom"] = "Jean" } ] }; var repository = new BatikRendezVousRepository(connection); bool cancelled = await repository.CancelAsync("42", new AnnulationRendezVousCommand { OrigineKey = "O1", MotifKey = "M1", Commentaire = "Client absent" }); Assert.True(cancelled); ExecutedCommand write = Assert.Single(connection.Writes); Assert.Equal( DescriptionInitiale + Environment.NewLine + "Note (Jean DUPONT) : Client absent", write.Parameters[3] as string); } // ========================================================================= // Helpers // ========================================================================= /// /// Vérifie que le nombre de paramètres positionnels correspond EXACTEMENT au /// nombre de ? du SQL : une estampille d'audit oubliée (ou en trop) /// décale les paramètres et corrompt silencieusement l'écriture. /// private static void AssertParametersMatchPlaceholders(ExecutedCommand command) { int placeholders = command.Sql.Count(c => c == '?'); Assert.Equal(placeholders, command.Parameters.Count); } private static Dictionary PlanningRow(long pkid, long idGroupe, long collaborateurId) => new() { ["PL_PKID"] = pkid, ["PL_Description"] = DescriptionInitiale, ["PL_IDGroupe"] = idGroupe, ["PL_IDCollaborateur"] = collaborateurId }; /// Référentiel minimal : origine, motif et statut « Annulé ». private static IReadOnlyList> ReferenceRows() => [ new Dictionary { ["PA_PKID"] = 2000L, ["PA_Code"] = "O1", ["PA_Libelle"] = "Externe : Client" }, new Dictionary { ["PA_PKID"] = 3000L, ["PA_Code"] = "M1", ["PA_Libelle"] = "Absent" }, new Dictionary { ["PA_PKID"] = 1000L, ["PA_Code"] = "15", ["PA_Valeur"] = "S.RDV.ANNU", ["PA_Libelle"] = "Annulé" } ]; private sealed record ExecutedCommand(string Sql, IReadOnlyList Parameters); private sealed class FakeHfsqlConnection : IHfsqlConnection { public HfsqlOptions Options => new(); public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed; private readonly List _commands = []; private readonly List _writes = []; /// Écritures (ExecuteNonQuery) dans l'ordre d'exécution. public IReadOnlyList Writes => _writes; /// Lignes retournées pour FROM Planning_RDV. public IReadOnlyList> PlanningRows { get; set; } = []; /// Lignes retournées pour FROM ParametreMetier. public IReadOnlyList> ParametreMetierRows { get; set; } = []; /// Lignes retournées pour FROM Collaborateur. public IReadOnlyList> CollaborateurRows { get; set; } = []; 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) { _commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? [])); return Task.FromResult(null); } public Task ExecuteNonQueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken cancellationToken = default) { var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []); _commands.Add(command); _writes.Add(command); return Task.FromResult(0); } public Task>> QueryAsync( string sql, IReadOnlyList? parameters = null, CancellationToken cancellationToken = default) { _commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? [])); return Task.FromResult(ResolveRows(sql)); } public Task> GetColumnsAsync( string tableName, CancellationToken cancellationToken = default) => Task.FromResult>([]); public ValueTask DisposeAsync() { State = HfsqlConnectionState.Closed; return ValueTask.CompletedTask; } private IReadOnlyList> ResolveRows(string sql) { if (ContainsFrom(sql, "Planning_RDV")) return PlanningRows; if (ContainsFrom(sql, "ParametreMetier")) return ParametreMetierRows; if (ContainsFrom(sql, "Collaborateur")) return CollaborateurRows; return []; } 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; } } }