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

239 lines
8.9 KiB
C#

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;
}
}
}