329 lines
12 KiB
C#
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 > 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;
|
|
}
|
|
}
|
|
}
|