1010 lines
37 KiB
C#
1010 lines
37 KiB
C#
using System.Reflection;
|
|
using Omika.Core.Contracts.Clients;
|
|
using Omika.Core.Contracts.Common;
|
|
using Omika.Core.Domain.Commands;
|
|
using Omika.Core.Domain.Entities.Clients;
|
|
using Omika.Core.Interfaces;
|
|
using Omika.Data.Configuration;
|
|
using Omika.Data.Connection;
|
|
using Omika.Data.HfSql.Metadata;
|
|
using Omika.Data.Repositories;
|
|
using Omika.Data.Repositories.Batik;
|
|
|
|
|
|
namespace Omika.Tests.Repositories.Batik;
|
|
|
|
/// <summary>
|
|
/// Tests unitaires de <see cref="BatikClientRepository"/> : aucun serveur HFSQL,
|
|
/// aucun driver PC SOFT, aucun ODBC réel, aucun réseau. Un fake
|
|
/// <see cref="IHfsqlConnection"/> enregistre le SQL et les paramètres et
|
|
/// retourne des dictionnaires par requête.
|
|
/// </summary>
|
|
public class BatikClientRepositoryTests
|
|
{
|
|
// =========================================================================
|
|
// SEARCH
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_ReturnsCanonicalClients()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows =
|
|
[
|
|
ClientRow(1, "Dupont", "C001", typeId: 10, statutId: 20, adressePrincipaleId: 100)
|
|
];
|
|
connection.ParametreMetierRows =
|
|
[
|
|
ParametreMetierRow(10, "TYP", "Entreprise"),
|
|
ParametreMetierRow(20, "STA", "Actif")
|
|
];
|
|
connection.AdresseRows =
|
|
[
|
|
AdresseRow(100, "Paris")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
PagedResult<Client> result = await repository.SearchAsync(new ClientSearchCriteria());
|
|
|
|
Client client = Assert.Single(result.Items);
|
|
Assert.Equal("Dupont", client.Nom);
|
|
Assert.Equal("C001", client.Code);
|
|
Assert.Equal("Entreprise", client.Type?.Label);
|
|
Assert.Equal("Actif", client.Statut?.Label);
|
|
Assert.Equal("Paris", client.AdressePrincipale?.AdressePostale?.City);
|
|
Assert.Equal(1, client.SourceReference?.NumericId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_EmptyResult_ReturnsEmptyPage()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
PagedResult<Client> result = await repository.SearchAsync(new ClientSearchCriteria());
|
|
|
|
Assert.Empty(result.Items);
|
|
Assert.Equal(0, result.TotalCount);
|
|
Assert.Equal(0, result.TotalPages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_PaginationRespectsPageAndPageSize()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows =
|
|
[
|
|
ClientRow(1, "Alpha", "C001"),
|
|
ClientRow(2, "Beta", "C002"),
|
|
ClientRow(3, "Gamma", "C003"),
|
|
ClientRow(4, "Delta", "C004"),
|
|
ClientRow(5, "Epsilon", "C005")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
PagedResult<Client> result = await repository.SearchAsync(
|
|
new ClientSearchCriteria { Page = 2, PageSize = 2, SortBy = "Nom" });
|
|
|
|
// Tri explicite : Nom ASC (Alpha, Beta, Delta, Epsilon, Gamma).
|
|
Assert.Equal(2, result.Items.Count);
|
|
Assert.Equal("Delta", result.Items[0].Nom);
|
|
Assert.Equal("Epsilon", result.Items[1].Nom);
|
|
Assert.Equal(5, result.TotalCount);
|
|
Assert.Equal(3, result.TotalPages);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_TotalCountIsTotalBeforePagination()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows =
|
|
[
|
|
ClientRow(1, "Alpha", "C001"),
|
|
ClientRow(2, "Beta", "C002"),
|
|
ClientRow(3, "Gamma", "C003")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
PagedResult<Client> result = await repository.SearchAsync(
|
|
new ClientSearchCriteria { Page = 1, PageSize = 2 });
|
|
|
|
Assert.Equal(2, result.Items.Count);
|
|
Assert.Equal(3, result.TotalCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_NomFilter_IsParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { Nom = "Dupont" });
|
|
|
|
Assert.Contains("CL_Nom LIKE ?", connection.ClientSql);
|
|
Assert.Equal("%Dupont%", connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_CodeFilter_IsParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { Code = "C001" });
|
|
|
|
Assert.Contains("CL_CodeClient LIKE ?", connection.ClientSql);
|
|
Assert.Equal("%C001%", connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_ArchiveFilter_IsParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { EstArchive = true });
|
|
|
|
Assert.Contains("Archive = ?", connection.ClientSql);
|
|
Assert.Equal(true, connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_BloqueFilter_IsParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { EstBloque = true });
|
|
|
|
Assert.Contains("CL_Blocage = ?", connection.ClientSql);
|
|
Assert.Equal(true, connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_GeneralSearch_IsParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { Search = "Dupont" });
|
|
|
|
Assert.Contains("(CL_Nom LIKE ? OR CL_CodeClient LIKE ?)", connection.ClientSql);
|
|
Assert.Equal("%Dupont%", connection.ClientParameters![0]);
|
|
Assert.Equal("%Dupont%", connection.ClientParameters![1]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_UserValueNeverConcatenatedInSql()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { Nom = "Dupont' OR '1'='1" });
|
|
|
|
Assert.DoesNotContain("Dupont", connection.ClientSql);
|
|
Assert.Contains("CL_Nom LIKE ?", connection.ClientSql);
|
|
Assert.Equal("%Dupont' OR '1'='1%", connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_SortByNom_UsesWhitelistedColumn()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { SortBy = "Nom" });
|
|
|
|
Assert.Contains("ORDER BY CL_Nom ASC", connection.ClientSql);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_SortByCode_UsesWhitelistedColumn()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { SortBy = "Code" });
|
|
|
|
Assert.Contains("ORDER BY CL_CodeClient ASC", connection.ClientSql);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_UnknownSortBy_NeverBecomesRawSql()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { SortBy = "Nom; DROP TABLE Client" });
|
|
|
|
Assert.Contains("ORDER BY ModifDate DESC", connection.ClientSql);
|
|
Assert.DoesNotContain("DROP", connection.ClientSql);
|
|
Assert.DoesNotContain(";", connection.ClientSql);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_SortDescending_ProducesControlledDesc()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria { SortBy = "Nom", SortDescending = true });
|
|
|
|
Assert.Contains("ORDER BY CL_Nom DESC", connection.ClientSql);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_DoesNotLoadContactsOrSites()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria());
|
|
|
|
string allSql = string.Join(" ", connection.AllSql);
|
|
Assert.DoesNotContain("FROM Personne", allSql, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("FROM Lieu", allSql, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("FROM Client_Adresse", allSql, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("FROM Client_Lieu", allSql, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("FROM Lieu_Personne", allSql, StringComparison.OrdinalIgnoreCase);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchAsync_CancellationTokenTransmitted()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(1, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
await repository.SearchAsync(new ClientSearchCriteria(), cts.Token);
|
|
|
|
Assert.Equal(cts.Token, connection.LastCancellationToken);
|
|
}
|
|
|
|
// =========================================================================
|
|
// DETAIL
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_ValidNumericId_QueriesClPkidWithParameter()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Contains("WHERE CL_PKID = ?", connection.ClientSql);
|
|
Assert.Equal(42L, connection.ClientParameters![0]);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_NonNumericId_ReturnsNullWithoutDetailQuery()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("abc");
|
|
|
|
Assert.Null(client);
|
|
Assert.Equal(0, connection.QueryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_GuidOnBatik_ReturnsNull()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync(Guid.NewGuid().ToString());
|
|
|
|
Assert.Null(client);
|
|
Assert.Equal(0, connection.QueryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_ClientAbsent_ReturnsNull()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.Null(client);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_BaseClientMappedCorrectly()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001", typeId: 10, statutId: 20)];
|
|
connection.ParametreMetierRows =
|
|
[
|
|
ParametreMetierRow(10, "TYP", "Entreprise"),
|
|
ParametreMetierRow(20, "STA", "Actif")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Equal("Dupont", client.Nom);
|
|
Assert.Equal("C001", client.Code);
|
|
Assert.Equal("Entreprise", client.Type?.Label);
|
|
Assert.Equal("Actif", client.Statut?.Label);
|
|
Assert.Equal(42, client.SourceReference?.NumericId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_AdressesAssembled()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ClientAdresseRows =
|
|
[
|
|
ClientAdresseRow(1, adresseId: 100, clientId: 42, typeAdresseId: 0, principale: false),
|
|
ClientAdresseRow(2, adresseId: 101, clientId: 42, typeAdresseId: 0, principale: false)
|
|
];
|
|
connection.AdresseRows =
|
|
[
|
|
AdresseRow(100, "Paris"),
|
|
AdresseRow(101, "Lyon")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Equal(2, client.Adresses.Count);
|
|
Assert.Contains(client.Adresses, a => a.AdressePostale?.City == "Paris");
|
|
Assert.Contains(client.Adresses, a => a.AdressePostale?.City == "Lyon");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_AdressePrincipaleResolved()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001", adressePrincipaleId: 100)];
|
|
connection.AdresseRows = [AdresseRow(100, "Paris")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.NotNull(client.AdressePrincipale);
|
|
Assert.Equal("Paris", client.AdressePrincipale.AdressePostale?.City);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_SitesAssembled()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ClientLieuRows = [ClientLieuRow(1, clientId: 42, lieuId: 500)];
|
|
connection.LieuRows = [LieuRow(500, "Site A", adresseId: 100, typeId: 0)];
|
|
connection.AdresseRows = [AdresseRow(100, "Paris")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Site site = Assert.Single(client.Sites);
|
|
Assert.Equal("Site A", site.Code);
|
|
Assert.Equal("Paris", site.Adresse?.City);
|
|
Assert.Equal(500, site.SourceReference?.NumericId);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_DirectContactsAssembled()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.PersonneRows =
|
|
[
|
|
PersonneRow(1, "Martin", "Jean", clientId: 42),
|
|
PersonneRow(2, "Durand", "Paul", clientId: 42)
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Equal(2, client.Contacts.Count);
|
|
Assert.Contains(client.Contacts, c => c.Nom == "Martin");
|
|
Assert.Contains(client.Contacts, c => c.Nom == "Durand");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_SiteContactsNonInclus()
|
|
{
|
|
// Les contacts de site (Lieu_Personne) appartiennent à la fiche site, pas
|
|
// à la fiche client : un contact lié uniquement à un site du client ne
|
|
// doit PAS apparaître dans la liste des contacts du client.
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ClientLieuRows = [ClientLieuRow(1, clientId: 42, lieuId: 500)];
|
|
connection.LieuRows = [LieuRow(500, "Site A", adresseId: 0, typeId: 0)];
|
|
connection.LieuPersonneRows = [LieuPersonneRow(1, personneId: 7, lieuId: 500)];
|
|
connection.PersonneRows = [PersonneRow(7, "Bernard", "Luc", clientId: 0)];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
// La table Lieu_Personne ne doit plus être interrogée : les contacts de
|
|
// site appartiennent à la fiche site, pas à la fiche client.
|
|
string allSql = string.Join(" ", connection.AllSql);
|
|
Assert.DoesNotContain("FROM Lieu_Personne", allSql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_DuplicatesEliminatedByAssembler()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ClientLieuRows = [ClientLieuRow(1, clientId: 42, lieuId: 500)];
|
|
connection.LieuRows = [LieuRow(500, "Site A", adresseId: 0, typeId: 0)];
|
|
// Même personne (PE_PKID = 7) en contact direct ET en contact de site.
|
|
connection.LieuPersonneRows = [LieuPersonneRow(1, personneId: 7, lieuId: 500)];
|
|
connection.PersonneRows = [PersonneRow(7, "Bernard", "Luc", clientId: 42)];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Single(client.Contacts);
|
|
Assert.Equal("Bernard", client.Contacts[0].Nom);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_ReferencesResolved()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001", typeId: 10, statutId: 20)];
|
|
connection.ParametreMetierRows =
|
|
[
|
|
ParametreMetierRow(10, "TYP", "Entreprise"),
|
|
ParametreMetierRow(20, "STA", "Actif")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Equal("Entreprise", client.Type?.Label);
|
|
Assert.Equal("Actif", client.Statut?.Label);
|
|
Assert.Equal(10, client.Type?.LegacyId);
|
|
Assert.Equal(20, client.Statut?.LegacyId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_NoGuidFabricatedForBatik()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? client = await repository.GetDetailsAsync("42");
|
|
|
|
Assert.NotNull(client);
|
|
Assert.Equal(42, client.SourceReference?.NumericId);
|
|
Assert.Null(client.SourceReference?.EntityGuid);
|
|
Assert.Null(client.SourceReference?.PhysicalGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_InfrastructureError_Propagates()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ThrowOnQuery = true;
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => repository.GetDetailsAsync("42"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDetailsAsync_SqlAlwaysParameterized()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ClientAdresseRows = [ClientAdresseRow(1, adresseId: 100, clientId: 42, typeAdresseId: 0, principale: false)];
|
|
connection.AdresseRows = [AdresseRow(100, "Paris")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.GetDetailsAsync("42");
|
|
|
|
// Aucune valeur utilisateur (42) ne doit apparaître en clair dans le SQL.
|
|
Assert.DoesNotContain("= 42", connection.AllSql);
|
|
Assert.DoesNotContain("IN (42)", connection.AllSql);
|
|
}
|
|
|
|
// =========================================================================
|
|
// ARCHITECTURE
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void Repository_ImplementsIClientRepository()
|
|
{
|
|
Assert.True(typeof(IClientRepository).IsAssignableFrom(typeof(BatikClientRepository)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Repository_InheritsRepositoryBase()
|
|
{
|
|
Assert.True(typeof(RepositoryBase).IsAssignableFrom(typeof(BatikClientRepository)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Repository_DoesNotReferenceOmikaModels()
|
|
{
|
|
var type = typeof(BatikClientRepository);
|
|
var referenced = GetPublicSurfaceTypes(type);
|
|
|
|
// Aucun type dont le nom contient « Omika » (modèles/mappers Omika) ne
|
|
// doit apparaître dans la surface publique. Le type lui-même est exclu.
|
|
Assert.DoesNotContain(referenced, t =>
|
|
t != type && t.Name.Contains("Omika", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void Repository_DoesNotExposeOdbcTypes()
|
|
{
|
|
var type = typeof(BatikClientRepository);
|
|
var referenced = GetPublicSurfaceTypes(type);
|
|
|
|
Assert.DoesNotContain(referenced, t =>
|
|
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Repository_DoesNotReturnClientDtos()
|
|
{
|
|
var type = typeof(BatikClientRepository);
|
|
|
|
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
|
{
|
|
Assert.DoesNotContain("ClientSummaryDto", method.ReturnType.Name);
|
|
Assert.DoesNotContain("ClientDetailsDto", method.ReturnType.Name);
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<Type> GetPublicSurfaceTypes(Type type)
|
|
{
|
|
var members = type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
|
|
.Select(m => m.DeclaringType)
|
|
.Where(t => t is not null)
|
|
.Cast<Type>();
|
|
|
|
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
|
.SelectMany(m => m.GetParameters())
|
|
.Select(p => p.ParameterType);
|
|
|
|
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
|
.Select(m => m.ReturnType);
|
|
|
|
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
|
.Select(p => p.PropertyType);
|
|
|
|
return members.Concat(parameters).Concat(returnTypes).Concat(properties);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers de construction de lignes
|
|
// =========================================================================
|
|
|
|
private static Dictionary<string, object?> ClientRow(
|
|
long pkid,
|
|
string? nom,
|
|
string? code,
|
|
long typeId = 0,
|
|
long statutId = 0,
|
|
long adressePrincipaleId = 0)
|
|
=> new()
|
|
{
|
|
["CL_PKID"] = pkid,
|
|
["CL_Nom"] = nom,
|
|
["CL_CodeClient"] = code,
|
|
["CL_IDType"] = typeId,
|
|
["CL_IDStatut"] = statutId,
|
|
["CL_IDAdressePrincipale"] = adressePrincipaleId,
|
|
["CL_Blocage"] = false,
|
|
["Archive"] = false
|
|
};
|
|
|
|
private static Dictionary<string, object?> ParametreMetierRow(long pkid, string code, string libelle)
|
|
=> new()
|
|
{
|
|
["PA_PKID"] = pkid,
|
|
["PA_Code"] = code,
|
|
["PA_Libelle"] = libelle,
|
|
["PA_Valeur"] = null
|
|
};
|
|
|
|
private static Dictionary<string, object?> AdresseRow(long pkid, string ville)
|
|
=> new()
|
|
{
|
|
["AD_PKID"] = pkid,
|
|
["AD_Ville"] = ville,
|
|
["AD_Ligne_1"] = null,
|
|
["AD_CodePostal"] = null,
|
|
["AD_Etiquette"] = null
|
|
};
|
|
|
|
private static Dictionary<string, object?> LieuRow(long pkid, string codeLieu, long adresseId, long typeId)
|
|
=> new()
|
|
{
|
|
["LI_PKID"] = pkid,
|
|
["LI_CodeLieu"] = codeLieu,
|
|
["LI_IDAdresse"] = adresseId,
|
|
["LI_IDType"] = typeId,
|
|
["Archive"] = false
|
|
};
|
|
|
|
private static Dictionary<string, object?> PersonneRow(long pkid, string nom, string prenom, long clientId)
|
|
=> new()
|
|
{
|
|
["PE_PKID"] = pkid,
|
|
["PE_Nom"] = nom,
|
|
["PE_Prenom"] = prenom,
|
|
["PE_IDClient"] = clientId,
|
|
["PE_IDCivilite"] = 0,
|
|
["PE_IDRole"] = 0,
|
|
["PE_Actif"] = true,
|
|
["Archive"] = false
|
|
};
|
|
|
|
private static Dictionary<string, object?> ClientAdresseRow(
|
|
long pkid,
|
|
long adresseId,
|
|
long clientId,
|
|
long typeAdresseId,
|
|
bool principale)
|
|
=> new()
|
|
{
|
|
["CA_PKID"] = pkid,
|
|
["CA_IDAdresse"] = adresseId,
|
|
["CA_IDClient"] = clientId,
|
|
["CA_IDTypeAdresse"] = typeAdresseId,
|
|
["CA_Principale"] = principale
|
|
};
|
|
|
|
private static Dictionary<string, object?> ClientLieuRow(long pkid, long clientId, long lieuId)
|
|
=> new()
|
|
{
|
|
["CLI_PKID"] = pkid,
|
|
["CLI_IDClient"] = clientId,
|
|
["CLI_IDLieu"] = lieuId
|
|
};
|
|
|
|
private static Dictionary<string, object?> LieuPersonneRow(long pkid, long personneId, long lieuId)
|
|
=> new()
|
|
{
|
|
["LIP_PKID"] = pkid,
|
|
["LIP_IDPersonne"] = personneId,
|
|
["LIP_IDLieu"] = lieuId
|
|
};
|
|
|
|
// =========================================================================
|
|
// Fake de connexion
|
|
// =========================================================================
|
|
|
|
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;
|
|
|
|
public int OpenCount { get; private set; }
|
|
public int QueryCount { get; private set; }
|
|
public bool ThrowOnQuery { get; set; }
|
|
|
|
public string? LastSql { get; private set; }
|
|
public IReadOnlyList<object?>? LastParameters { get; private set; }
|
|
public CancellationToken LastCancellationToken { get; private set; }
|
|
public IReadOnlyList<string> AllSql { get; } = new List<string>();
|
|
public IReadOnlyList<ExecutedCommand> Commands => _commands;
|
|
private readonly List<ExecutedCommand> _commands = [];
|
|
|
|
// SQL et paramètres de la requête principale « FROM Client » (la plus
|
|
// pertinente pour vérifier les filtres et le tri de la recherche).
|
|
public string? ClientSql { get; private set; }
|
|
public IReadOnlyList<object?>? ClientParameters { get; private set; }
|
|
private void Record(string sql, IReadOnlyList<object?>? parameters, CancellationToken cancellationToken)
|
|
{
|
|
LastSql = sql;
|
|
LastParameters = parameters;
|
|
LastCancellationToken = cancellationToken;
|
|
QueryCount++;
|
|
((List<string>)AllSql).Add(sql);
|
|
_commands.Add(new ExecutedCommand(sql, parameters?.ToArray() ?? []));
|
|
|
|
if (ContainsFrom(sql, "Client"))
|
|
{
|
|
ClientSql = sql;
|
|
ClientParameters = parameters;
|
|
}
|
|
}
|
|
|
|
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ClientRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ParametreMetierRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> AdresseRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> LieuRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PersonneRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ClientAdresseRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> ClientLieuRows { get; set; } = [];
|
|
public IReadOnlyList<IReadOnlyDictionary<string, object?>> LieuPersonneRows { get; set; } = [];
|
|
|
|
public Task OpenAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
State = HfsqlConnectionState.Open;
|
|
OpenCount++;
|
|
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)
|
|
{
|
|
Record(sql, parameters, cancellationToken);
|
|
return Task.FromResult<object?>(null);
|
|
}
|
|
|
|
public Task<int> ExecuteNonQueryAsync(
|
|
string sql,
|
|
IReadOnlyList<object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Record(sql, parameters, cancellationToken);
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
|
string sql,
|
|
IReadOnlyList<object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Record(sql, parameters, cancellationToken);
|
|
|
|
if (ThrowOnQuery)
|
|
{
|
|
throw new InvalidOperationException("Panne d'infrastructure simulée.");
|
|
}
|
|
|
|
return Task.FromResult(ResolveRows(sql));
|
|
}
|
|
|
|
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
|
string tableName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
State = HfsqlConnectionState.Closed;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(string sql)
|
|
|
|
{
|
|
// Ordre de spécificité : les tables composées avant les tables simples.
|
|
if (ContainsFrom(sql, "Client_Adresse")) return ClientAdresseRows;
|
|
if (ContainsFrom(sql, "Client_Lieu")) return ClientLieuRows;
|
|
if (ContainsFrom(sql, "Lieu_Personne")) return LieuPersonneRows;
|
|
if (ContainsFrom(sql, "ParametreMetier")) return ParametreMetierRows;
|
|
if (ContainsFrom(sql, "Adresse")) return AdresseRows;
|
|
if (ContainsFrom(sql, "Lieu")) return LieuRows;
|
|
if (ContainsFrom(sql, "Personne")) return PersonneRows;
|
|
if (ContainsFrom(sql, "Client")) return ClientRows;
|
|
return [];
|
|
}
|
|
|
|
private static bool ContainsFrom(string sql, string table)
|
|
{
|
|
// Le nom de table doit être suivi d'un espace, d'une fin de chaîne ou
|
|
// d'un caractère non-identifiant, afin de ne pas matcher une table
|
|
// composée (ex. « FROM Client » ne doit pas matcher « FROM Client_Lieu »).
|
|
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;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// UPDATE
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_ValidNumericId_UpdatesClient()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Orig", "C001")];
|
|
connection.ParametreMetierRows = [];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
var command = new ClientUpdateCommand { Nom = "Updated", Code = "C002" };
|
|
await repository.UpdateAsync("42", command);
|
|
|
|
string updateSql = connection.AllSql.First(s => s.Contains("UPDATE Client SET"));
|
|
Assert.Contains("UPDATE Client SET", updateSql);
|
|
Assert.Contains("WHERE CL_PKID = ?", updateSql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_InvalidId_ReturnsNullNoQuery()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? result = await repository.UpdateAsync("abc", new ClientUpdateCommand());
|
|
|
|
Assert.Null(result);
|
|
Assert.Equal(0, connection.QueryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_AbsentClient_ReturnsNull()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? result = await repository.UpdateAsync("42", new ClientUpdateCommand());
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_NullPut_SendsDefaults()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.UpdateAsync("42", new ClientUpdateCommand());
|
|
|
|
// L'UPDATE a bien été exécuté (pas d'exception).
|
|
string updateSql = connection.AllSql.First(s => s.StartsWith("UPDATE Client SET"));
|
|
Assert.NotNull(updateSql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_TypeStatutResolved()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
connection.ParametreMetierRows =
|
|
[
|
|
ParametreMetierRow(10, "ENT", "Entreprise"),
|
|
ParametreMetierRow(20, "ACT", "Actif")
|
|
];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.UpdateAsync("42", new ClientUpdateCommand { TypeKey = "Entreprise", StatutKey = "Actif" });
|
|
|
|
var updateCmd = connection.Commands.Single(c => c.Sql.StartsWith("UPDATE Client SET"));
|
|
object[] p = updateCmd.Parameters.ToArray()!;
|
|
Assert.Equal(10L, p[2]); // CL_IDType
|
|
Assert.Equal(20L, p[3]); // CL_IDStatut
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_NoGuidFabricated()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
Client? result = await repository.UpdateAsync("42", new ClientUpdateCommand { Nom = "X" });
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Null(result.SourceReference?.EntityGuid);
|
|
}
|
|
|
|
// =========================================================================
|
|
// ARCHIVE
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public async Task ArchiveAsync_Existing_ReturnsTrue()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
bool result = await repository.ArchiveAsync("42");
|
|
|
|
Assert.True(result);
|
|
Assert.Contains("UPDATE Client SET Archive", connection.LastSql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArchiveAsync_InvalidId_ReturnsFalse()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
bool result = await repository.ArchiveAsync("abc");
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArchiveAsync_Absent_ReturnsFalse()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
bool result = await repository.ArchiveAsync("42");
|
|
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ArchiveAsync_NoPhysicalDelete()
|
|
{
|
|
var connection = new FakeHfsqlConnection();
|
|
connection.ClientRows = [ClientRow(42, "Dupont", "C001")];
|
|
var repository = new BatikClientRepository(connection);
|
|
|
|
await repository.ArchiveAsync("42");
|
|
|
|
Assert.DoesNotContain("DELETE", connection.LastSql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
|
|
|