Premiere commit - Omika
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Services.Mobile;
|
||||
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 la création d'un compte rendu Batik
|
||||
/// (<see cref="BatikCompteRenduRepository.CreateAsync"/>) centrés sur la colonne
|
||||
/// <c>CR_IDCollaborateur</c> : 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>la colonne porte l'identité du collaborateur AUTEUR du compte rendu, et
|
||||
/// non plus systématiquement le collaborateur affecté à <c>Planning_RDV</c> ;</item>
|
||||
/// <item>le PK numérique du jeton mobile est repris tel quel, et le GUID
|
||||
/// synthétique réversible Batik est décodé (la table <c>Collaborateur</c> Batik
|
||||
/// ne porte aucune colonne GUID) ;</item>
|
||||
/// <item>à défaut d'auteur résolvable, le collaborateur affecté au rendez-vous
|
||||
/// reste le repli historique : la colonne n'est jamais laissée vide.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class BatikCompteRenduRepositoryTests
|
||||
{
|
||||
private const long RendezVousId = 42L;
|
||||
private const long CollaborateurDuRendezVous = 7L;
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurNumerique_RenseigneLAuteurEtNonLeCollaborateurDuRendezVous()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command("9"));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
// 9 (auteur) et non 7 (collaborateur affecté à Planning_RDV).
|
||||
Assert.Equal(9L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurGuidSynthetique_DecodeLePkNumerique()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
// Identité publique Batik : GUID synthétique réversible dérivé du CO_PKID 55.
|
||||
Guid auteurSynthetique = MobileEntityIdentityResolver.EncodeSyntheticIdentity(
|
||||
SyncEntityTypes.Collaborateur, 55L);
|
||||
|
||||
await repository.CreateAsync("42", Command(auteurSynthetique.ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(55L, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_SansAuteur_RetombeSurLeCollaborateurDuRendezVous()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command(collaborateurId: null));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(CollaborateurDuRendezVous, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AuteurNonResolvable_RetombeSurLeCollaborateurDuRendezVous()
|
||||
{
|
||||
// GUID non canonique (ni PK numérique, ni GUID synthétique réversible) :
|
||||
// la colonne est remplie avec le repli plutôt que laissée à 0.
|
||||
var connection = new FakeHfsqlConnection
|
||||
{
|
||||
PlanningRows = [PlanningRow()],
|
||||
CompteRenduRows = [CompteRenduRow()]
|
||||
};
|
||||
var repository = new BatikCompteRenduRepository(connection);
|
||||
|
||||
await repository.CreateAsync("42", Command(Guid.NewGuid().ToString("D")));
|
||||
|
||||
ExecutedCommand insert = Insert(connection);
|
||||
Assert.Equal(CollaborateurDuRendezVous, CrIdCollaborateur(insert));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static CompteRenduCreateCommand Command(string? collaborateurId)
|
||||
=> new()
|
||||
{
|
||||
CollaborateurId = collaborateurId,
|
||||
NoteCollaborateur = "Note du technicien"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> PlanningRow()
|
||||
=> new()
|
||||
{
|
||||
["PL_PKID"] = RendezVousId,
|
||||
["PL_IDIntervention"] = 12L,
|
||||
["PL_IDCollaborateur"] = CollaborateurDuRendezVous,
|
||||
["PL_IDGroupe"] = 0L
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> CompteRenduRow()
|
||||
=> new()
|
||||
{
|
||||
["CR_PKID"] = 1L,
|
||||
["GUID"] = Guid.NewGuid().ToString("D")
|
||||
};
|
||||
|
||||
private static ExecutedCommand Insert(FakeHfsqlConnection connection)
|
||||
=> Assert.Single(connection.Writes.Where(write =>
|
||||
write.Sql.Contains("INSERT INTO CompteRendu", StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
|
||||
/// <summary>Valeur du paramètre de la colonne <c>CR_IDCollaborateur</c>.</summary>
|
||||
private static object? CrIdCollaborateur(ExecutedCommand insert)
|
||||
=> HfsqlInsertParameters.ValueFor(insert.Sql, insert.Parameters, "CR_IDCollaborateur");
|
||||
|
||||
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 CompteRendu</c>.</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CompteRenduRows { 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, "CompteRendu"))
|
||||
return CompteRenduRows;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
using Omika.Core.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
namespace Omika.Tests.Repositories.Batik;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du repository d'identité mobile Batik.
|
||||
/// Vérifie que le chemin historique Batik continue de résoudre les permissions
|
||||
/// (UT_IDTypeProfil → AccesEtDroit.AED_IDProfil + modules ParametreMetier) et
|
||||
/// que l'utilisateur est désormais résolu par UT_PKID (et non par UT_Login).
|
||||
/// </summary>
|
||||
public class BatikMobileIdentityRepositoryTests
|
||||
{
|
||||
private const long UserIdTest = 1001;
|
||||
private const long ProfilIdTest = 3001;
|
||||
private const long ModulePkTest = 5001;
|
||||
private const string LoginTest = "TEST_USER";
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermissionsAsync_ResoutParUT_PKID_EtRetourneALicMob()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
connection.ParametreMetierRows.Add(ModuleRow(ModulePkTest, "M.PlClb.11")); // → A.LIC.MOB
|
||||
connection.AccesEtDroitRows.Add(DroitRow(ProfilIdTest, ModulePkTest, true));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
IReadOnlyDictionary<string, bool> permissions =
|
||||
await repository.GetPermissionsAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.True(permissions["A.LIC.MOB"]);
|
||||
|
||||
string utilisateurSql = connection.Queries.First(q => q.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("UT_PKID = ?", utilisateurSql);
|
||||
Assert.DoesNotContain("UT_Login = ?", utilisateurSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLoginByUserIdAsync_RetourneLoginReel()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
string? login = await repository.GetLoginByUserIdAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal(LoginTest, login);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProfileAsync_ResoutProfilBatik()
|
||||
{
|
||||
var connection = new FakeHfsqlConnection(HfsqlConnectionState.Open);
|
||||
connection.UtilisateurRows.Add(UtilisateurRow(UserIdTest, LoginTest, ProfilIdTest));
|
||||
connection.ParametreMetierRows.Add(ProfilRow(ProfilIdTest, "BATIK_PROFIL"));
|
||||
var repository = new BatikMobileIdentityRepository(connection);
|
||||
|
||||
MobileProfileDto profile = await repository.GetProfileAsync(UserIdTest.ToString());
|
||||
|
||||
Assert.Equal("PROFIL", profile.Code);
|
||||
Assert.Equal("BATIK_PROFIL", profile.Nom);
|
||||
}
|
||||
|
||||
// ===== Fixtures =====
|
||||
|
||||
private static Dictionary<string, object?> UtilisateurRow(long pkId, string login, long profilId) => new()
|
||||
{
|
||||
["UT_PKID"] = pkId,
|
||||
["UT_Login"] = login,
|
||||
["UT_IDCollaborateur"] = 2001L,
|
||||
["UT_IDTypeProfil"] = profilId,
|
||||
["UT_AccesMobile"] = true
|
||||
};
|
||||
|
||||
// Module de la catégorie 29 (« Modules intervenir ») : PA_Valeur = clé Batik.
|
||||
private static Dictionary<string, object?> ModuleRow(long pkId, string valeur) => new()
|
||||
{
|
||||
["PA_PKID"] = pkId,
|
||||
["PA_Code"] = "29",
|
||||
["PA_Libelle"] = "Module",
|
||||
["PA_Valeur"] = valeur,
|
||||
["PA_Actif"] = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> ProfilRow(long pkId, string libelle) => new()
|
||||
{
|
||||
["PA_PKID"] = pkId,
|
||||
["PA_Code"] = "PROFIL",
|
||||
["PA_Libelle"] = libelle,
|
||||
["PA_Valeur"] = libelle,
|
||||
["PA_Actif"] = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> DroitRow(long profilId, long moduleId, bool acces) => new()
|
||||
{
|
||||
["AED_PKID"] = 4001L,
|
||||
["AED_IDProfil"] = profilId,
|
||||
["AED_IDModule"] = moduleId,
|
||||
["AED_Acces"] = acces
|
||||
};
|
||||
|
||||
// ===== Fake =====
|
||||
|
||||
private sealed class FakeHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
public FakeHfsqlConnection(HfsqlConnectionState state) => State = state;
|
||||
|
||||
public HfsqlOptions Options => new();
|
||||
public HfsqlConnectionState State { get; private set; }
|
||||
|
||||
public List<Dictionary<string, object?>> UtilisateurRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> ParametreMetierRows { get; } = [];
|
||||
public List<Dictionary<string, object?>> AccesEtDroitRows { get; } = [];
|
||||
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
State = HfsqlConnectionState.Open;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
State = HfsqlConnectionState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(sql);
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> result = sql switch
|
||||
{
|
||||
_ when sql.Contains("FROM Utilisateur", StringComparison.OrdinalIgnoreCase) => UtilisateurRows,
|
||||
_ when sql.Contains("FROM ParametreMetier", StringComparison.OrdinalIgnoreCase) => ParametreMetierRows,
|
||||
_ when sql.Contains("FROM AccesEtDroit", StringComparison.OrdinalIgnoreCase) => AccesEtDroitRows,
|
||||
_ => []
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user