Files
Omika/tests/Omika.Tests/Repositories/Batik/BatikRendezVousAnnulationTests.cs
T
2026-10-23 18:46:50 +02:00

329 lines
12 KiB
C#

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;
/// <summary>
/// Tests unitaires de l'annulation d'un rendez-vous Batik
/// (<see cref="BatikRendezVousRepository.CancelAsync"/>) : aucun serveur HFSQL,
/// aucun ODBC réel, aucun réseau. Un fake <see cref="IHfsqlConnection"/> enregistre
/// le SQL et les paramètres et retourne des dictionnaires selon la table interrogée.
///
/// Objectifs :
/// <list type="bullet">
/// <item>un rendez-vous INDIVIDUEL (<c>PL_IDGroupe = 0</c>) n'annule que sa propre
/// ligne <c>Planning_RDV</c> (ciblée par <c>PL_PKID</c>) ;</item>
/// <item>un rendez-vous GROUPÉ (<c>PL_IDGroupe &gt; 0</c>) annule TOUTES les lignes
/// du groupe (ciblées par <c>PL_IDGroupe</c>) : tous les rendez-vous du groupe sont
/// impactés ;</item>
/// <item>la description reçoit la mention d'annulation
/// <c>Note (Nom) : message</c>, le nom étant résolu côté serveur s'il n'est pas
/// fourni.</item>
/// </list>
/// </summary>
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<DateTime>(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<DateTime>(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<string, object?>
{
["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
// =========================================================================
/// <summary>
/// Vérifie que le nombre de paramètres positionnels correspond EXACTEMENT au
/// nombre de <c>?</c> du SQL : une estampille d'audit oubliée (ou en trop)
/// décale les paramètres et corrompt silencieusement l'écriture.
/// </summary>
private static void AssertParametersMatchPlaceholders(ExecutedCommand command)
{
int placeholders = command.Sql.Count(c => c == '?');
Assert.Equal(placeholders, command.Parameters.Count);
}
private static Dictionary<string, object?> PlanningRow(long pkid, long idGroupe, long collaborateurId)
=> new()
{
["PL_PKID"] = pkid,
["PL_Description"] = DescriptionInitiale,
["PL_IDGroupe"] = idGroupe,
["PL_IDCollaborateur"] = collaborateurId
};
/// <summary>Référentiel minimal : origine, motif et statut « Annulé ».</summary>
private static IReadOnlyList<IReadOnlyDictionary<string, object?>> ReferenceRows() =>
[
new Dictionary<string, object?>
{
["PA_PKID"] = 2000L,
["PA_Code"] = "O1",
["PA_Libelle"] = "Externe : Client"
},
new Dictionary<string, object?>
{
["PA_PKID"] = 3000L,
["PA_Code"] = "M1",
["PA_Libelle"] = "Absent"
},
new Dictionary<string, object?>
{
["PA_PKID"] = 1000L,
["PA_Code"] = "15",
["PA_Valeur"] = "S.RDV.ANNU",
["PA_Libelle"] = "Annulé"
}
];
private sealed record ExecutedCommand(string Sql, IReadOnlyList<object?> Parameters);
private sealed class FakeHfsqlConnection : IHfsqlConnection
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
private readonly List<ExecutedCommand> _commands = [];
private readonly List<ExecutedCommand> _writes = [];
/// <summary>Écritures (ExecuteNonQuery) dans l'ordre d'exécution.</summary>
public IReadOnlyList<ExecutedCommand> Writes => _writes;
/// <summary>Lignes retournées pour <c>FROM Planning_RDV</c>.</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PlanningRows { get; set; } = [];
/// <summary>Lignes retournées pour <c>FROM ParametreMetier</c>.</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ParametreMetierRows { get; set; } = [];
/// <summary>Lignes retournées pour <c>FROM Collaborateur</c>.</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> 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<object?> ExecuteScalarAsync(
string sql,
IReadOnlyList<object?>? parameters = null,
CancellationToken cancellationToken = default)
{
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
return Task.FromResult<object?>(null);
}
public Task<int> ExecuteNonQueryAsync(
string sql,
IReadOnlyList<object?>? parameters = null,
CancellationToken cancellationToken = default)
{
var command = new ExecutedCommand(sql, parameters?.ToArray() ?? []);
_commands.Add(command);
_writes.Add(command);
return Task.FromResult(0);
}
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql,
IReadOnlyList<object?>? parameters = null,
CancellationToken cancellationToken = default)
{
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
return Task.FromResult(ResolveRows(sql));
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
string tableName,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync()
{
State = HfsqlConnectionState.Closed;
return ValueTask.CompletedTask;
}
private IReadOnlyList<IReadOnlyDictionary<string, object?>> 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;
}
}
}