Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de la règle métier unique de composition de la mention d'annulation :
|
||||
/// le mot « Note », le nom du collaborateur entre parenthèses, puis son message
|
||||
/// (<c>Note (DUPONT Jean) : Client absent</c>). Cette règle est partagée par le
|
||||
/// serveur (repositories Batik/Omika) et l'application mobile (copie locale
|
||||
/// offline-first), qui doivent écrire exactement le même texte.
|
||||
/// </summary>
|
||||
public class AnnulationRendezVousDescriptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComposerNote_MessageEtNom_InscritNoteNomPuisMessage()
|
||||
{
|
||||
string? note = AnnulationRendezVousDescription.ComposerNote("Client absent", "Jean DUPONT");
|
||||
|
||||
Assert.Equal("Note (Jean DUPONT) : Client absent", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposerNote_NomInconnu_InscritNoteEtMessageSansParentheses()
|
||||
{
|
||||
// Aucun nom connu : aucune parenthèse vide ne doit apparaître.
|
||||
Assert.Equal("Note : Client absent", AnnulationRendezVousDescription.ComposerNote("Client absent", null));
|
||||
Assert.Equal("Note : Client absent", AnnulationRendezVousDescription.ComposerNote("Client absent", " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposerNote_SansMessage_NeComposeRien()
|
||||
{
|
||||
// Sans message du collaborateur, RIEN n'est inscrit dans la description :
|
||||
// la mention trace une note, pas l'annulation elle-même (le statut, le
|
||||
// motif et l'origine portent déjà l'information). Le nom seul n'est jamais
|
||||
// inscrit.
|
||||
Assert.Null(AnnulationRendezVousDescription.ComposerNote(null, "Jean DUPONT"));
|
||||
Assert.Null(AnnulationRendezVousDescription.ComposerNote(" ", "Jean DUPONT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposerNote_AucuneInformation_RetourneNull()
|
||||
{
|
||||
Assert.Null(AnnulationRendezVousDescription.ComposerNote(null, null));
|
||||
Assert.Null(AnnulationRendezVousDescription.ComposerNote(" ", " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposerNote_NettoieLesEspaces()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Note (Jean DUPONT) : Client absent",
|
||||
AnnulationRendezVousDescription.ComposerNote(" Client absent ", " Jean DUPONT "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concatener_DescriptionExistante_AjouteLaMentionSurUneNouvelleLigne()
|
||||
{
|
||||
string? resultat = AnnulationRendezVousDescription.Concatener(
|
||||
"Description initiale", "Client absent", "Jean DUPONT");
|
||||
|
||||
Assert.Equal(
|
||||
"Description initiale" + Environment.NewLine + "Note (Jean DUPONT) : Client absent",
|
||||
resultat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concatener_DescriptionVide_RetourneLaMentionSeule()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Note (Jean DUPONT) : Client absent",
|
||||
AnnulationRendezVousDescription.Concatener(null, "Client absent", "Jean DUPONT"));
|
||||
Assert.Equal(
|
||||
"Note (Jean DUPONT) : Client absent",
|
||||
AnnulationRendezVousDescription.Concatener(" ", "Client absent", "Jean DUPONT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concatener_AucuneNote_LaisseLaDescriptionInchangee()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Description initiale",
|
||||
AnnulationRendezVousDescription.Concatener("Description initiale", null, null));
|
||||
|
||||
// Un nom sans message ne modifie jamais la description.
|
||||
Assert.Equal(
|
||||
"Description initiale",
|
||||
AnnulationRendezVousDescription.Concatener("Description initiale", null, "Jean DUPONT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concatener_MentionDejaPresente_EstIdempotent()
|
||||
{
|
||||
// Rejeu d'une opération de synchronisation dont la réponse a été perdue :
|
||||
// la mention ne doit jamais être ajoutée deux fois.
|
||||
string description = "Description initiale" + Environment.NewLine
|
||||
+ "Note (Jean DUPONT) : Client absent";
|
||||
|
||||
string? resultat = AnnulationRendezVousDescription.Concatener(description, "Client absent", "Jean DUPONT");
|
||||
|
||||
Assert.Equal(description, resultat);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Configuration;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Contracts.Organisation;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Organisation;
|
||||
using Omika.Core.Domain.ValueObjects;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Services;
|
||||
|
||||
using Omika.Core.Domain;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="CollaborateurService"/> avec un fake
|
||||
/// <see cref="ICollaborateurRepository"/> en mémoire. Aucune base réelle.
|
||||
/// </summary>
|
||||
public class CollaborateurServiceTests
|
||||
{
|
||||
// =========================================================================
|
||||
// SEARCH
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_CallsRepositoryOnce()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest());
|
||||
|
||||
Assert.Equal(1, repo.SearchCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsPage()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { Page = 3 });
|
||||
|
||||
Assert.Equal(3, repo.LastSearchCriteria?.Page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsPageSize()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { PageSize = 50 });
|
||||
|
||||
Assert.Equal(50, repo.LastSearchCriteria?.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsSearch()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { Search = "test" });
|
||||
|
||||
Assert.Equal("test", repo.LastSearchCriteria?.Search);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsEquipeId()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { EquipeId = "equipe-1" });
|
||||
|
||||
Assert.Equal("equipe-1", repo.LastSearchCriteria?.EquipeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsActif()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { Actif = true });
|
||||
|
||||
Assert.True(repo.LastSearchCriteria?.Actif);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsEstArchive()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { EstArchive = true });
|
||||
|
||||
Assert.True(repo.LastSearchCriteria?.EstArchive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsSortBy()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { SortBy = "Nom" });
|
||||
|
||||
Assert.Equal("Nom", repo.LastSearchCriteria?.SortBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsSortDescending()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest { SortDescending = true });
|
||||
|
||||
Assert.True(repo.LastSearchCriteria?.SortDescending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_TransmitsCancellationToken()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.SearchAsync(new SearchCollaborateurRequest(), cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, repo.LastCancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_EmptyResult_ReturnsEmptyPage()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>([], 1, 20, 0, 0)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest());
|
||||
|
||||
Assert.Empty(result.Items);
|
||||
Assert.Equal(0, result.TotalCount);
|
||||
Assert.Equal(0, result.TotalPages);
|
||||
Assert.Equal(1, result.Page);
|
||||
Assert.Equal(20, result.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_SingleCollaborateur_MapsToSummaryDto()
|
||||
{
|
||||
var collaborateur = new Collaborateur
|
||||
{
|
||||
Nom = "Dupont",
|
||||
Prenom = "Jean",
|
||||
Code = "JD",
|
||||
Coordonnees = new ContactDetails { Email = "jean@example.com", Phone = "0102030405" },
|
||||
Actif = true
|
||||
};
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>([collaborateur], 1, 20, 1, 1)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest());
|
||||
|
||||
CollaborateurSummaryDto dto = Assert.Single(result.Items);
|
||||
Assert.Equal("Jean Dupont", dto.NomAffiche);
|
||||
Assert.Equal("JD", dto.Code);
|
||||
Assert.Equal("jean@example.com", dto.Email);
|
||||
Assert.Equal("0102030405", dto.Telephone);
|
||||
Assert.True(dto.Actif);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_MultipleCollaborateurs_MapsAllToDtos()
|
||||
{
|
||||
var collaborateurList = new[]
|
||||
{
|
||||
new Collaborateur { Nom = "Alpha" },
|
||||
new Collaborateur { Nom = "Beta" },
|
||||
new Collaborateur { Nom = "Gamma" }
|
||||
};
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>(collaborateurList, 1, 20, 3, 1)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest());
|
||||
|
||||
Assert.Equal(3, result.Items.Count);
|
||||
Assert.Equal(3, result.TotalCount);
|
||||
Assert.Equal("Alpha", result.Items[0].NomAffiche);
|
||||
Assert.Equal("Beta", result.Items[1].NomAffiche);
|
||||
Assert.Equal("Gamma", result.Items[2].NomAffiche);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_PreservesTotalCount()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>([], 2, 10, 42, 5)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest());
|
||||
|
||||
Assert.Equal(42, result.TotalCount);
|
||||
Assert.Equal(2, result.Page);
|
||||
Assert.Equal(10, result.PageSize);
|
||||
Assert.Equal(5, result.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_IdComesFromCollaborateurContractMapper()
|
||||
{
|
||||
var collaborateur = new Collaborateur { Nom = "Test" };
|
||||
var expectedId = collaborateur.Id.ToString();
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>([collaborateur], 1, 20, 1, 1)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest());
|
||||
|
||||
Assert.Equal(expectedId, result.Items[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ServiceDoesNotRePaginate()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
SearchResult = new PagedResult<Collaborateur>(
|
||||
[new Collaborateur { Nom = "A" }, new Collaborateur { Nom = "B" }], 3, 15, 100, 7)
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
PagedResult<CollaborateurSummaryDto> result = await service.SearchAsync(
|
||||
new SearchCollaborateurRequest { Page = 1, PageSize = 5 });
|
||||
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
Assert.Equal(100, result.TotalCount);
|
||||
Assert.Equal(7, result.TotalPages);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DETAIL
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_TransmitsExactId()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.GetByIdAsync("test-id-123");
|
||||
|
||||
Assert.Equal("test-id-123", repo.LastDetailsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_TransmitsCancellationToken()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.GetByIdAsync("42", cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, repo.LastCancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_RepositoryReturnsNull_ReturnsNull()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { DetailsResult = null };
|
||||
var service = CreateService(repo);
|
||||
|
||||
CollaborateurDetailsDto? result = await service.GetByIdAsync("999");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_MapsToDetailsDto()
|
||||
{
|
||||
var collaborateur = new Collaborateur
|
||||
{
|
||||
Nom = "Dupont",
|
||||
Prenom = "Jean",
|
||||
Code = "JD",
|
||||
Civilite = new ReferenceValue("M.") { Key = "M" },
|
||||
Statut = new ReferenceValue("Actif") { Key = "ACTIF" },
|
||||
Coordonnees = new ContactDetails { Email = "jean@example.com" }
|
||||
};
|
||||
var repo = new FakeCollaborateurRepository { DetailsResult = collaborateur };
|
||||
var service = CreateService(repo);
|
||||
|
||||
CollaborateurDetailsDto? result = await service.GetByIdAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Dupont", result.Nom);
|
||||
Assert.Equal("Jean", result.Prenom);
|
||||
Assert.Equal("JD", result.Code);
|
||||
Assert.Equal("M.", result.Civilite?.Label);
|
||||
Assert.Equal("Actif", result.Statut?.Label);
|
||||
Assert.Equal("jean@example.com", result.Coordonnees?.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_IdComesFromMapper()
|
||||
{
|
||||
var collaborateur = new Collaborateur { Nom = "Test" };
|
||||
var expectedId = collaborateur.Id.ToString();
|
||||
var repo = new FakeCollaborateurRepository { DetailsResult = collaborateur };
|
||||
var service = CreateService(repo);
|
||||
|
||||
CollaborateurDetailsDto? result = await service.GetByIdAsync("42");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedId, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NoGuidParsingInService()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.GetByIdAsync("not-a-guid-nor-int");
|
||||
|
||||
Assert.Equal("not-a-guid-nor-int", repo.LastDetailsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_RepositoryException_Propagated()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
ThrowOnDetails = new InvalidOperationException("Infrastructure error")
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.GetByIdAsync("42"));
|
||||
|
||||
Assert.Equal("Infrastructure error", ex.Message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CREATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CallsRepositoryOnce()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
CreateResult = new Collaborateur { Nom = "Nouveau" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.CreateAsync(new CreateCollaborateurRequest { Nom = "Nouveau" });
|
||||
|
||||
Assert.Equal(1, repo.CreateCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_MapsAllProperties()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
CreateResult = new Collaborateur { Nom = "Nouveau" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.CreateAsync(new CreateCollaborateurRequest
|
||||
{
|
||||
Code = "JD",
|
||||
Nom = "Dupont",
|
||||
Prenom = "Jean",
|
||||
CiviliteKey = "M",
|
||||
StatutKey = "ACTIF",
|
||||
Email = "jean@example.com",
|
||||
Telephone = "0102030405",
|
||||
Mobile = "0607080910",
|
||||
Fonction = "Développeur",
|
||||
SocieteId = "societe-1",
|
||||
CouleurPlanning = "#FF0000",
|
||||
Actif = true
|
||||
});
|
||||
|
||||
Assert.Equal("JD", repo.LastCreateCommand!.Code);
|
||||
Assert.Equal("Dupont", repo.LastCreateCommand!.Nom);
|
||||
Assert.Equal("Jean", repo.LastCreateCommand!.Prenom);
|
||||
Assert.Equal("M", repo.LastCreateCommand!.CiviliteKey);
|
||||
Assert.Equal("ACTIF", repo.LastCreateCommand!.StatutKey);
|
||||
Assert.Equal("jean@example.com", repo.LastCreateCommand!.Email);
|
||||
Assert.Equal("0102030405", repo.LastCreateCommand!.Telephone);
|
||||
Assert.Equal("0607080910", repo.LastCreateCommand!.Mobile);
|
||||
Assert.Equal("Développeur", repo.LastCreateCommand!.Fonction);
|
||||
Assert.Equal("societe-1", repo.LastCreateCommand!.SocieteId);
|
||||
Assert.Equal("#FF0000", repo.LastCreateCommand!.CouleurPlanning);
|
||||
Assert.True(repo.LastCreateCommand!.Actif);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ResultMappedToDetails()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
CreateResult = new Collaborateur { Nom = "Nouveau" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
CollaborateurDetailsDto result = await service.CreateAsync(new CreateCollaborateurRequest { Nom = "Nouveau" });
|
||||
|
||||
Assert.Equal("Nouveau", result.Nom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CancellationTokenTransmitted()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
CreateResult = new Collaborateur { Nom = "Nouveau" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.CreateAsync(new CreateCollaborateurRequest { Nom = "Nouveau" }, cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, repo.LastCreateCancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ExceptionPropagated()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
ThrowOnCreate = new InvalidOperationException("Panne")
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.CreateAsync(new CreateCollaborateurRequest { Nom = "Nouveau" }));
|
||||
|
||||
Assert.Equal("Panne", ex.Message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UPDATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MapsAllProperties()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
UpdateResult = new Collaborateur { Nom = "Updated" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.UpdateAsync("42", new UpdateCollaborateurRequest
|
||||
{
|
||||
Code = "JD",
|
||||
Nom = "Updated",
|
||||
CiviliteKey = "M",
|
||||
StatutKey = "ACTIF"
|
||||
});
|
||||
|
||||
Assert.Equal(1, repo.UpdateCallCount);
|
||||
Assert.Equal("42", repo.LastUpdateId);
|
||||
Assert.Equal("JD", repo.LastUpdateCommand!.Code);
|
||||
Assert.Equal("Updated", repo.LastUpdateCommand!.Nom);
|
||||
Assert.Equal("M", repo.LastUpdateCommand!.CiviliteKey);
|
||||
Assert.Equal("ACTIF", repo.LastUpdateCommand!.StatutKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_NullsPreserved()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { UpdateResult = new Collaborateur { Nom = "X" } };
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.UpdateAsync("42", new UpdateCollaborateurRequest());
|
||||
|
||||
Assert.Null(repo.LastUpdateCommand!.CiviliteKey);
|
||||
Assert.Null(repo.LastUpdateCommand!.StatutKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RepoReturnsNull_ThrowsKeyNotFound()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { UpdateResult = null };
|
||||
var service = CreateService(repo);
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => service.UpdateAsync("42", new UpdateCollaborateurRequest()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_IdTransmittedExactly()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
UpdateResult = new Collaborateur { Nom = "XY" }
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.UpdateAsync("my-guid-123", new UpdateCollaborateurRequest());
|
||||
|
||||
Assert.Equal("my-guid-123", repo.LastUpdateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_CancellationTokenTransmitted()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { UpdateResult = new Collaborateur { Nom = "X" } };
|
||||
var service = CreateService(repo);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.UpdateAsync("42", new UpdateCollaborateurRequest(), cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, repo.LastUpdateCancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ExceptionPropagated()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
ThrowOnUpdate = new InvalidOperationException("Panne")
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.UpdateAsync("42", new UpdateCollaborateurRequest()));
|
||||
|
||||
Assert.Equal("Panne", ex.Message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHIVE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_TruePropagated()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { ArchiveResult = true };
|
||||
var service = CreateService(repo);
|
||||
|
||||
bool result = await service.ArchiveAsync("42");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, repo.ArchiveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_False_ReturnsFalse()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { ArchiveResult = false };
|
||||
var service = CreateService(repo);
|
||||
|
||||
bool result = await service.ArchiveAsync("42");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_IdTransmittedExactly()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { ArchiveResult = true };
|
||||
var service = CreateService(repo);
|
||||
|
||||
await service.ArchiveAsync("my-guid-123");
|
||||
|
||||
Assert.Equal("my-guid-123", repo.LastArchiveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_CancellationTokenTransmitted()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository { ArchiveResult = true };
|
||||
var service = CreateService(repo);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.ArchiveAsync("42", cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, repo.LastArchiveCancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ExceptionPropagated()
|
||||
{
|
||||
var repo = new FakeCollaborateurRepository
|
||||
{
|
||||
ThrowOnArchive = new InvalidOperationException("Panne")
|
||||
};
|
||||
var service = CreateService(repo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ArchiveAsync("42"));
|
||||
|
||||
Assert.Equal("Panne", ex.Message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ARCHITECTURE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Service_ImplementsICollaborateurService()
|
||||
{
|
||||
Assert.True(typeof(ICollaborateurService).IsAssignableFrom(typeof(CollaborateurService)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_DependsOnICollaborateurRepository()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
bool hasRepoParam = type.GetConstructors()
|
||||
.Any(c => c.GetParameters()
|
||||
.Any(p => p.ParameterType == typeof(ICollaborateurRepository)));
|
||||
|
||||
Assert.True(hasRepoParam, "CollaborateurService doit dépendre de ICollaborateurRepository.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoOmikaDataReferences()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("Omika.Data"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoBatikReferences()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Name.Contains("Batik", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoHfsqlReferences()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Name.Contains("Row", StringComparison.OrdinalIgnoreCase)
|
||||
&& t.Namespace is not null
|
||||
&& (t.Namespace.Contains("HfSql", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.Namespace.Contains("Models.Omika", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoSqlStatements()
|
||||
{
|
||||
string source = ReadSourceFile(typeof(CollaborateurService));
|
||||
|
||||
Assert.DoesNotContain("SELECT ", source, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("FROM ", source, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoApplicationRuntimeContext()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Name.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoDatabaseSchemaVersion()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Name.Contains("DatabaseSchemaVersion", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoOdbcReferences()
|
||||
{
|
||||
var type = typeof(CollaborateurService);
|
||||
var refs = GetReferencedTypes(type);
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private static CollaborateurService CreateService(FakeCollaborateurRepository repo)
|
||||
=> new CollaborateurService(
|
||||
repo,
|
||||
new FakeIdentityResolver(),
|
||||
new FakeRendezVousService(),
|
||||
new FakeParametreMetierRepository());
|
||||
|
||||
private sealed class FakeRendezVousService : IRendezVousService
|
||||
{
|
||||
public Task<PagedResult<RendezVousSummaryDto>> SearchAsync(SearchRendezVousRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<RendezVousSummaryDto>([], 1, 20, 0, 0));
|
||||
|
||||
public Task<PagedResult<RendezVousDetailsDto>> SearchDetailsAsync(SearchRendezVousRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<RendezVousDetailsDto>([], 1, 20, 0, 0));
|
||||
|
||||
public Task<RendezVousDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RendezVousDetailsDto?>(null);
|
||||
|
||||
public Task<RendezVousDetailsDto> CreateAsync(CreateRendezVousRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<RendezVousDetailsDto> UpdateAsync(string id, UpdateRendezVousRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task CancelAsync(string id, AnnulationRendezVousDto annulation, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task ArchiveAsync(string id, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<bool> SetTempsValideAsync(string rendezVousId, string collaborateurId, bool tempsValide, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class FakeParametreMetierRepository : IParametreMetierRepository
|
||||
{
|
||||
public IReadOnlyList<ParametreMetierDto> Items { get; set; } = [];
|
||||
|
||||
public Task<IReadOnlyList<ParametreMetierDto>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Items);
|
||||
}
|
||||
|
||||
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
|
||||
{
|
||||
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
|
||||
=> physical.Guid ?? Guid.Empty;
|
||||
|
||||
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
|
||||
=> null;
|
||||
|
||||
public bool IsCanonical(string entityType, Guid publicGuid)
|
||||
=> publicGuid != Guid.Empty;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetReferencedTypes(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);
|
||||
|
||||
var ctorParams = type.GetConstructors()
|
||||
.SelectMany(c => c.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties).Concat(ctorParams);
|
||||
}
|
||||
|
||||
private static string ReadSourceFile(Type type)
|
||||
{
|
||||
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var dir = new DirectoryInfo(baseDir);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
|
||||
dir = dir.Parent;
|
||||
|
||||
string root = dir?.FullName ?? baseDir;
|
||||
return File.ReadAllText(Path.Combine(root, "src", "Omika.Core", "Services", type.Name + ".cs"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FAKE REPOSITORY
|
||||
// =========================================================================
|
||||
|
||||
private sealed class FakeCollaborateurRepository : ICollaborateurRepository
|
||||
{
|
||||
public int SearchCallCount { get; private set; }
|
||||
public int DetailsCallCount { get; private set; }
|
||||
public int UpdateCallCount { get; private set; }
|
||||
public int ArchiveCallCount { get; private set; }
|
||||
public int CreateCallCount { get; private set; }
|
||||
public CollaborateurSearchCriteria? LastSearchCriteria { get; private set; }
|
||||
public string? LastDetailsId { get; private set; }
|
||||
public string? LastUpdateId { get; private set; }
|
||||
public string? LastArchiveId { get; private set; }
|
||||
public CollaborateurUpdateCommand? LastUpdateCommand { get; private set; }
|
||||
public CollaborateurCreateCommand? LastCreateCommand { get; private set; }
|
||||
public CancellationToken LastUpdateCancellationToken { get; private set; }
|
||||
public CancellationToken LastArchiveCancellationToken { get; private set; }
|
||||
public CancellationToken LastCreateCancellationToken { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public PagedResult<Collaborateur>? SearchResult { get; set; }
|
||||
public Collaborateur? DetailsResult { get; set; }
|
||||
public Collaborateur? UpdateResult { get; set; }
|
||||
public Collaborateur? CreateResult { get; set; }
|
||||
public bool ArchiveResult { get; set; }
|
||||
public Exception? ThrowOnSearch { get; set; }
|
||||
public Exception? ThrowOnDetails { get; set; }
|
||||
public Exception? ThrowOnUpdate { get; set; }
|
||||
public Exception? ThrowOnArchive { get; set; }
|
||||
public Exception? ThrowOnCreate { get; set; }
|
||||
|
||||
public Task<Collaborateur> CreateAsync(
|
||||
CollaborateurCreateCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CreateCallCount++;
|
||||
LastCreateCommand = command;
|
||||
LastCreateCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnCreate is not null)
|
||||
throw ThrowOnCreate;
|
||||
|
||||
return Task.FromResult(CreateResult ?? new Collaborateur { Nom = command.Nom });
|
||||
}
|
||||
|
||||
public Task<PagedResult<Collaborateur>> SearchAsync(
|
||||
CollaborateurSearchCriteria criteria,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCallCount++;
|
||||
LastSearchCriteria = criteria;
|
||||
LastCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnSearch is not null)
|
||||
throw ThrowOnSearch;
|
||||
|
||||
return Task.FromResult(SearchResult
|
||||
?? new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
||||
}
|
||||
|
||||
public Task<PagedResult<Collaborateur>> SearchDetailsAsync(
|
||||
CollaborateurSearchCriteria criteria,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCallCount++;
|
||||
LastSearchCriteria = criteria;
|
||||
LastCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnSearch is not null)
|
||||
throw ThrowOnSearch;
|
||||
|
||||
return Task.FromResult(SearchResult
|
||||
?? new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
||||
}
|
||||
|
||||
public Task<Collaborateur?> GetDetailsAsync(
|
||||
|
||||
string id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DetailsCallCount++;
|
||||
LastDetailsId = id;
|
||||
LastCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnDetails is not null)
|
||||
throw ThrowOnDetails;
|
||||
|
||||
return Task.FromResult(DetailsResult);
|
||||
}
|
||||
|
||||
public Task<CollaborateurUtilisateur?> GetUtilisateurByCollaborateurAsync(
|
||||
string collaborateurId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CollaborateurUtilisateur?>(null);
|
||||
|
||||
public Task<string?> GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<Collaborateur?> UpdateAsync(
|
||||
string id, CollaborateurUpdateCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
UpdateCallCount++;
|
||||
LastUpdateId = id;
|
||||
LastUpdateCommand = command;
|
||||
LastUpdateCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnUpdate is not null)
|
||||
throw ThrowOnUpdate;
|
||||
|
||||
return Task.FromResult(UpdateResult);
|
||||
}
|
||||
|
||||
public Task<bool> ArchiveAsync(
|
||||
string id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArchiveCallCount++;
|
||||
LastArchiveId = id;
|
||||
LastArchiveCancellationToken = cancellationToken;
|
||||
|
||||
if (ThrowOnArchive is not null)
|
||||
throw ThrowOnArchive;
|
||||
|
||||
return Task.FromResult(ArchiveResult);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<long>>([]);
|
||||
|
||||
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<long>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Omika.Core.Contracts.Interventions;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Interventions;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="CompteRenduService"/> avec des fakes en mémoire.
|
||||
/// Aucune base réelle, aucune dépendance Omika.Data.
|
||||
/// </summary>
|
||||
public class CompteRenduServiceTests
|
||||
{
|
||||
// =========================================================================
|
||||
// GET
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRendezVousAsync_RepositoryReturnsNull_ReturnsNull()
|
||||
{
|
||||
var repo = new FakeCompteRenduRepository(existing: null);
|
||||
var service = new CompteRenduService(repo, new FakeIdentityResolver());
|
||||
|
||||
CompteRenduDto? result = await service.GetByRendezVousAsync("rdv-1");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRendezVousAsync_MapsAndEchoesRendezVousId()
|
||||
{
|
||||
var existing = new CompteRendu
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NoteCollaborateur = "Intervention terminée",
|
||||
NomSignataire = "Jean Client",
|
||||
DevisConforme = true
|
||||
};
|
||||
var repo = new FakeCompteRenduRepository(existing);
|
||||
var service = new CompteRenduService(repo, new FakeIdentityResolver());
|
||||
|
||||
CompteRenduDto? result = await service.GetByRendezVousAsync("rdv-1");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("rdv-1", result!.RendezVousId);
|
||||
Assert.Equal("Intervention terminée", result.NoteCollaborateur);
|
||||
Assert.Equal("Jean Client", result.NomSignataire);
|
||||
Assert.True(result.DevisConforme);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CREATE
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Idempotent_ReturnsExistingWithoutCreating()
|
||||
{
|
||||
var existing = new CompteRendu { Id = Guid.NewGuid(), NoteCollaborateur = "Existant" };
|
||||
var repo = new FakeCompteRenduRepository(existing);
|
||||
var service = new CompteRenduService(repo, new FakeIdentityResolver());
|
||||
|
||||
CompteRenduDto result = await service.CreateAsync("rdv-1", new CreateCompteRenduRequest());
|
||||
|
||||
Assert.Equal("Existant", result.NoteCollaborateur);
|
||||
Assert.Equal(0, repo.CreateCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_MapsRequestFieldsToCommand()
|
||||
{
|
||||
var repo = new FakeCompteRenduRepository(existing: null);
|
||||
var service = new CompteRenduService(repo, new FakeIdentityResolver());
|
||||
|
||||
await service.CreateAsync("rdv-1", new CreateCompteRenduRequest
|
||||
{
|
||||
CollaborateurId = "collab-1",
|
||||
NoteCollaborateur = "Note collab",
|
||||
NomSignataire = "Client",
|
||||
NoteSignataire = "Note client",
|
||||
NomSignataireTech = "Tech",
|
||||
DevisConforme = true,
|
||||
AvecReserve = true,
|
||||
ReserveCommentaire = "Réserve",
|
||||
TypeSignataire = new Omika.Core.Contracts.Common.ReferenceValueDto { Key = "CLIENT", LegacyId = 42 }
|
||||
});
|
||||
|
||||
Assert.Equal(1, repo.CreateCallCount);
|
||||
CompteRenduCreateCommand? command = repo.LastCommand;
|
||||
Assert.NotNull(command);
|
||||
Assert.Equal("collab-1", command!.CollaborateurId);
|
||||
Assert.Equal("Note collab", command.NoteCollaborateur);
|
||||
Assert.Equal("Client", command.NomSignataire);
|
||||
Assert.Equal("Note client", command.NoteSignataire);
|
||||
Assert.Equal("Tech", command.NomSignataireTech);
|
||||
Assert.True(command.DevisConforme);
|
||||
Assert.True(command.AvecReserve);
|
||||
Assert.Equal("Réserve", command.ReserveCommentaire);
|
||||
Assert.Equal("CLIENT", command.TypeSignataireKey);
|
||||
Assert.Equal(42, command.TypeSignataireLegacyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ResolvesSyntheticGuidToNumericId()
|
||||
{
|
||||
var repo = new FakeCompteRenduRepository(existing: null);
|
||||
Guid publicGuid = Guid.NewGuid();
|
||||
var resolver = new FakeIdentityResolver(numericId: 12345);
|
||||
var service = new CompteRenduService(repo, resolver);
|
||||
|
||||
await service.CreateAsync(publicGuid.ToString("D"), new CreateCompteRenduRequest());
|
||||
|
||||
Assert.Equal(1, repo.CreateCallCount);
|
||||
Assert.Equal("12345", repo.LastRendezVousId);
|
||||
Assert.Equal(publicGuid, resolver.LastResolvedGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_ImplementsICompteRenduService()
|
||||
{
|
||||
Assert.True(typeof(ICompteRenduService).IsAssignableFrom(typeof(CompteRenduService)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_HasNoOmikaDataReferences()
|
||||
{
|
||||
var refs = GetReferencedTypes(typeof(CompteRenduService));
|
||||
|
||||
Assert.DoesNotContain(refs, t =>
|
||||
t.Namespace is not null && t.Namespace.StartsWith("Omika.Data"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FAKES
|
||||
// =========================================================================
|
||||
|
||||
private sealed class FakeCompteRenduRepository : ICompteRenduRepository
|
||||
{
|
||||
private readonly CompteRendu? _existing;
|
||||
|
||||
public FakeCompteRenduRepository(CompteRendu? existing)
|
||||
{
|
||||
_existing = existing;
|
||||
}
|
||||
|
||||
public int CreateCallCount { get; private set; }
|
||||
|
||||
public string? LastRendezVousId { get; private set; }
|
||||
|
||||
public CompteRenduCreateCommand? LastCommand { get; private set; }
|
||||
|
||||
public Task<CompteRendu?> GetByRendezVousAsync(string rendezVousId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_existing);
|
||||
|
||||
public Task<CompteRendu> CreateAsync(string rendezVousId, CompteRenduCreateCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CreateCallCount++;
|
||||
LastRendezVousId = rendezVousId;
|
||||
LastCommand = command;
|
||||
|
||||
var created = new CompteRendu
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
NoteCollaborateur = command.NoteCollaborateur,
|
||||
NomSignataire = command.NomSignataire,
|
||||
NoteSignataire = command.NoteSignataire,
|
||||
NomSignataireTech = command.NomSignataireTech,
|
||||
DevisConforme = command.DevisConforme,
|
||||
AvecReserve = command.AvecReserve,
|
||||
ReserveCommentaire = command.ReserveCommentaire
|
||||
};
|
||||
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
|
||||
{
|
||||
private readonly long? _numericId;
|
||||
|
||||
public FakeIdentityResolver(long? numericId = null)
|
||||
{
|
||||
_numericId = numericId;
|
||||
}
|
||||
|
||||
public Guid? LastResolvedGuid { get; private set; }
|
||||
|
||||
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
|
||||
=> physical.Guid ?? Guid.NewGuid();
|
||||
|
||||
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
|
||||
{
|
||||
LastResolvedGuid = publicGuid;
|
||||
return _numericId is { } numericId
|
||||
? new PhysicalEntityIdentity { NumericId = numericId }
|
||||
: null;
|
||||
}
|
||||
|
||||
public bool IsCanonical(string entityType, Guid publicGuid) => true;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetReferencedTypes(Type type)
|
||||
{
|
||||
var members = type.GetMembers(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static)
|
||||
.Select(m => m.DeclaringType)
|
||||
.Where(t => t is not null)
|
||||
.Cast<Type>();
|
||||
|
||||
var parameters = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||
.SelectMany(m => m.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
var returnTypes = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||
.Select(m => m.ReturnType);
|
||||
|
||||
var properties = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||
.Select(p => p.PropertyType);
|
||||
|
||||
var ctorParams = type.GetConstructors()
|
||||
.SelectMany(c => c.GetParameters())
|
||||
.Select(p => p.ParameterType);
|
||||
|
||||
return members.Concat(parameters).Concat(returnTypes).Concat(properties).Concat(ctorParams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Contracts.Interventions;
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Core.Domain.Entities.Documents;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie que la sortie du repository Omika (champ simple + champ « Tableau »
|
||||
/// avec <c>ListeMembre</c> et données JSON) est correctement substituée par
|
||||
/// <see cref="EmailCreationService"/> : champ simple remplacé, tableau formaté,
|
||||
/// aucune chaîne JSON brute injectée.
|
||||
/// </summary>
|
||||
public class EmailCreationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ApercuAsync_TableauEtSimple_SubstitutionSansJsonBrut()
|
||||
{
|
||||
var modeleMailRepository = new FakeModeleMailRepository
|
||||
{
|
||||
Modele = new ModeleMail { Sujet = "$REFINTERNE$ - $TABRENDEZVOUS$" },
|
||||
CorpsDocx = CreerDocx("Réf: $REFINTERNE$\nTableau: $TABRENDEZVOUS$")
|
||||
};
|
||||
|
||||
var rapportChampService = new FakeRapportChampService
|
||||
{
|
||||
Champs =
|
||||
[
|
||||
new RapportChampDto { Rubrique = "$REFINTERNE$", ListeDonnesBD = "ABC-123" },
|
||||
new RapportChampDto
|
||||
{
|
||||
Rubrique = "$TABRENDEZVOUS$",
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = "[{\"objet\":\"Objet A\"}]",
|
||||
ListeMembre =
|
||||
[
|
||||
new RapportChampSousElementDto { Rubrique = "$TABRDV.OBJET$", MembreMob = "saObjet" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var service = new EmailCreationService(
|
||||
modeleMailRepository,
|
||||
rapportChampService,
|
||||
new FakeInterventionService());
|
||||
|
||||
EmailModeleApercuDto? apercu = await service.ApercuAsync(
|
||||
new CreateEmailModeleRequest { ModeleId = "1", InterventionId = "guid" });
|
||||
|
||||
Assert.NotNull(apercu);
|
||||
Assert.Equal("ABC-123 - Objet A", apercu!.Sujet);
|
||||
Assert.Contains("ABC-123", apercu.Corps);
|
||||
Assert.Contains("Objet A", apercu.Corps);
|
||||
Assert.DoesNotContain("[{", apercu.Corps);
|
||||
Assert.DoesNotContain("$REFINTERNE$", apercu.Corps);
|
||||
Assert.DoesNotContain("$TABRENDEZVOUS$", apercu.Corps);
|
||||
}
|
||||
|
||||
private static byte[] CreerDocx(string texte)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
Body body = new Body();
|
||||
body.Append(new Paragraph(new Run(new Text(texte))));
|
||||
mainPart.Document.Append(body);
|
||||
mainPart.Document.Save();
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private sealed class FakeModeleMailRepository : IModeleMailRepository
|
||||
{
|
||||
public ModeleMail? Modele { get; set; }
|
||||
public byte[]? CorpsDocx { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<ModeleMail>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ModeleMail>>(Modele is null ? [] : [Modele]);
|
||||
|
||||
public Task<ModeleMail?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Modele);
|
||||
|
||||
public Task<byte[]?> GetCorpsDocxAsync(long modeleMailId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(CorpsDocx);
|
||||
}
|
||||
|
||||
private sealed class FakeRapportChampService : IRapportChampService
|
||||
{
|
||||
public IReadOnlyList<RapportChampDto> Champs { get; set; } = [];
|
||||
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsAsync(
|
||||
GetRapportChampsRequest request,
|
||||
bool toutesRub = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Champs);
|
||||
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsMailAsync(
|
||||
GetRapportChampsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Champs);
|
||||
}
|
||||
|
||||
private sealed class FakeInterventionService : IInterventionService
|
||||
{
|
||||
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
|
||||
SearchInterventionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
|
||||
SearchInterventionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<InterventionDetailsDto?> GetByIdAsync(
|
||||
string id,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<InterventionDetailsDto?>(null);
|
||||
|
||||
public Task<InterventionDetailsDto> CreateAsync(
|
||||
CreateInterventionRequest request,
|
||||
CancellationToken cancellationToken = default,
|
||||
AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<InterventionDetailsDto?> UpdateAsync(
|
||||
string id,
|
||||
UpdateInterventionRequest request,
|
||||
CancellationToken cancellationToken = default,
|
||||
AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
|
||||
string interventionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
||||
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(
|
||||
string interventionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
|
||||
|
||||
public Task<NoteInterneDto> AddNoteAsync(
|
||||
string interventionId,
|
||||
string contenu,
|
||||
AuditActor? auteur,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
|
||||
string interventionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
|
||||
|
||||
public Task<EmailInterventionDto> AddEmailAsync(
|
||||
string interventionId,
|
||||
CreateEmailInterventionRequest request,
|
||||
AuditActor? auteur,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du moteur Email Omika : rendu DOCX (substitution simple/UN + duplication
|
||||
/// de lignes de tableau Word) + conversion HTML + formatage par TypeMembre/masque
|
||||
/// + suppression des jetons connus. Reproduit TraiteDocumentRapportMob /
|
||||
/// TraiteLigneTableauRapportMob / TraitementSelonTypeRubRapportMob.
|
||||
/// </summary>
|
||||
public class EmailEngineTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static string ContactsJson(params (string Nom, string TelFixe, string TelMobile)[] contacts)
|
||||
{
|
||||
var items = contacts.Select(c => new
|
||||
{
|
||||
id = Guid.NewGuid().ToString(),
|
||||
nom = c.Nom,
|
||||
telFixe = c.TelFixe,
|
||||
telMobile = c.TelMobile,
|
||||
role = "Occupant",
|
||||
etage = "4"
|
||||
});
|
||||
return JsonSerializer.Serialize(items, Json);
|
||||
}
|
||||
|
||||
private static RapportChampDto Champ(string rubrique, string? type = "ChpSimpleChaine", string? listeDonnesBD = null, string? masque = null)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = rubrique,
|
||||
Type = type,
|
||||
TypeMembre = type,
|
||||
MasqueSaisie = masque,
|
||||
ListeDonnesBD = listeDonnesBD
|
||||
};
|
||||
|
||||
private static RapportChampDto TableauContacts(string rubrique, string prefixe, string json, params string[] membres)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = rubrique,
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre = membres.Select(m => new RapportChampSousElementDto
|
||||
{
|
||||
Rubrique = $"{prefixe}.{m}$",
|
||||
Type = "ChpSimpleChaine",
|
||||
TypeMembre = "ChpSimpleChaine",
|
||||
MembreMob = m
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static byte[] CreerDocx(params string[] paragraphes)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
foreach (string texte in paragraphes)
|
||||
{
|
||||
body.Append(new Paragraph(new Run(new Text(texte))));
|
||||
}
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Crée un DOCX avec un paragraphe puis un tableau (une ligne par entrée).</summary>
|
||||
private static byte[] CreerDocxAvecTable(params string[][] lignes)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
var table = new Table();
|
||||
foreach (string[] ligne in lignes)
|
||||
{
|
||||
var row = new TableRow();
|
||||
foreach (string cellule in ligne)
|
||||
{
|
||||
row.Append(new TableCell(new Paragraph(new Run(new Text(cellule)))));
|
||||
}
|
||||
table.Append(row);
|
||||
}
|
||||
body.Append(table);
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string Rendre(IReadOnlyList<RapportChampDto> champs, byte[] docx)
|
||||
=> DocxHtmlConverter.Convertir(EmailDocxRenderer.Rendre(docx, champs));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tableau → lignes Word clonées → HTML
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Tableau_DeuxContacts_ProduitDeuxLignesHtml()
|
||||
{
|
||||
string json = ContactsJson(
|
||||
("Moreau Valérie", "04 13 95 46 73", "06 12 73 59 74"),
|
||||
("Francois Clara", "04 2 68 54 47", "06 79 12 80 16"));
|
||||
|
||||
var champs = new[]
|
||||
{
|
||||
TableauContacts("$TABCONTACTSITE$", "$TABCTSITE", json, "nom", "telFixe", "telMobile")
|
||||
};
|
||||
|
||||
byte[] docx = CreerDocxAvecTable(
|
||||
["Nom", "Tel. 1", "Tel. 2"],
|
||||
["$TABCTSITE.nom$", "$TABCTSITE.telFixe$", "$TABCTSITE.telMobile$"]);
|
||||
|
||||
string html = Rendre(champs, docx);
|
||||
|
||||
Assert.Contains("<table", html);
|
||||
Assert.Contains("<tr", html);
|
||||
Assert.Contains("<td", html);
|
||||
Assert.Contains("Moreau Valérie", html);
|
||||
Assert.Contains("Francois Clara", html);
|
||||
Assert.Contains("04 13 95 46 73", html);
|
||||
Assert.Contains("06 79 12 80 16", html);
|
||||
Assert.DoesNotContain("$TABCTSITE", html);
|
||||
Assert.DoesNotContain("[{", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tableau_Vide_PlaceholdersSupprimes()
|
||||
{
|
||||
var champs = new[]
|
||||
{
|
||||
TableauContacts("$TABPHOTO$", "$TABPHOTO", "[]", "IMAGE", "DATEIMAGE", "NOTEIMAGE")
|
||||
};
|
||||
|
||||
byte[] docx = CreerDocxAvecTable(["$TABPHOTO.IMAGE$", "$TABPHOTO.DATEIMAGE$", "$TABPHOTO.NOTEIMAGE$"]);
|
||||
|
||||
string html = Rendre(champs, docx);
|
||||
|
||||
Assert.DoesNotContain("$TABPHOTO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tableau_ParentSansTable_RepliTexte()
|
||||
{
|
||||
// Modèle simplifié (Batik) : le jeton PARENT est dans le texte, pas de table Word.
|
||||
var champs = new[]
|
||||
{
|
||||
TableauContacts("$TABRENDEZVOUS$", "$TABRDV", "[{\"objet\":\"Objet A\"}]", "objet")
|
||||
};
|
||||
|
||||
byte[] docx = CreerDocx("Tableau: $TABRENDEZVOUS$");
|
||||
string html = Rendre(champs, docx);
|
||||
|
||||
Assert.Contains("Objet A", html);
|
||||
Assert.DoesNotContain("$TABRENDEZVOUS$", html);
|
||||
Assert.DoesNotContain("$TABRDV", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Formatage TypeMembre / MasqueSaisie
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ChpSimpleDate_Formate_JourMoisAnnee()
|
||||
{
|
||||
var champs = new[]
|
||||
{
|
||||
Champ("$UNRDV.DATEDEBUT_D$", "ChpSimpleDate", "15/04/2022 15:00:00", "JJ/MM/AAAA")
|
||||
};
|
||||
string html = Rendre(champs, CreerDocx("Début: $UNRDV.DATEDEBUT_D$"));
|
||||
|
||||
Assert.Contains("15/04/2022", html);
|
||||
Assert.DoesNotContain("15/04/2022 15:00:00", html);
|
||||
Assert.DoesNotContain("$UNRDV", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpSimpleDateHeure_Formate_AvecHeure()
|
||||
{
|
||||
var champs = new[]
|
||||
{
|
||||
Champ("$UNRDV.DATEDEBUT$", "ChpSimpleDateHeure", "15/04/2022 15:00:00", "JJ/MM/AAAA HH:mm")
|
||||
};
|
||||
string html = Rendre(champs, CreerDocx("Début: $UNRDV.DATEDEBUT$"));
|
||||
|
||||
Assert.Contains("15/04/2022 15:00", html);
|
||||
Assert.DoesNotContain("15/04/2022 15:00:00", html);
|
||||
Assert.DoesNotContain("$UNRDV", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpSimpleBooleen_Formate_OuiNon()
|
||||
{
|
||||
var champs = new[]
|
||||
{
|
||||
Champ("$UNCR.DEVISCONFORME$", "ChpSimpleBooleen", "Oui"),
|
||||
Champ("$UNCR.AVECRESERVE$", "ChpSimpleBooleen", "Non")
|
||||
};
|
||||
string html = Rendre(champs, CreerDocx("Conforme: $UNCR.DEVISCONFORME$ / Réserve: $UNCR.AVECRESERVE$"));
|
||||
|
||||
Assert.Contains("Conforme: Oui", html);
|
||||
Assert.Contains("Réserve: Non", html);
|
||||
Assert.DoesNotContain("$UNCR", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_20002_Date_Formate()
|
||||
{
|
||||
var champs = new[] { Champ("$Date_du_rapport_1$", "20002", "30/09/2026") };
|
||||
string html = Rendre(champs, CreerDocx("Date: $Date_du_rapport_1$"));
|
||||
|
||||
Assert.Contains("30/09/2026", html);
|
||||
Assert.DoesNotContain("$Date_du_rapport_1$", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_20003_Heure_Formate()
|
||||
{
|
||||
var champs = new[] { Champ("$Heure_1$", "20003", "08:30") };
|
||||
string html = Rendre(champs, CreerDocx("Heure: $Heure_1$"));
|
||||
|
||||
Assert.Contains("08:30", html);
|
||||
Assert.DoesNotContain("$Heure_1$", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_6_Booleen_Formate_Oui()
|
||||
{
|
||||
var champs = new[] { Champ("$Bool_1$", "6", "true") };
|
||||
string html = Rendre(champs, CreerDocx("Bool: $Bool_1$"));
|
||||
|
||||
Assert.Contains("Bool: Oui", html);
|
||||
Assert.DoesNotContain("$Bool_1$", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extra_20001_Texte_Brut()
|
||||
{
|
||||
var champs = new[] { Champ("$Texte_1$", "20001", "Bonjour") };
|
||||
string html = Rendre(champs, CreerDocx("Texte: $Texte_1$"));
|
||||
|
||||
Assert.Contains("Texte: Bonjour", html);
|
||||
Assert.DoesNotContain("$Texte_1$", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Corps HTML + sujet texte + placeholders connus
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Corps_EstHtml_AvecParagraphes()
|
||||
{
|
||||
var champs = new[] { Champ("$NOMCLIENT$", "ChpSimpleChaine", "DigiNova") };
|
||||
string html = Rendre(champs, CreerDocx("Bonjour $NOMCLIENT$,", "Veuillez trouver ci-joint le rapport."));
|
||||
|
||||
Assert.Contains("<p>", html);
|
||||
Assert.Contains("Bonjour DigiNova", html);
|
||||
Assert.Contains("Veuillez trouver ci-joint le rapport.", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceholdersConnusRestants_Supprimes()
|
||||
{
|
||||
// $TABPHOTO.IMAGE$ (ChpImage) sans valeur : aucun chemin/base64 ne doit apparaître.
|
||||
var champs = new[]
|
||||
{
|
||||
Champ("$TABPHOTO.IMAGE$", "ChpImage", null),
|
||||
Champ("$NOMCLIENT$", "ChpSimpleChaine", "DigiNova")
|
||||
};
|
||||
string html = Rendre(champs, CreerDocx("Photo: $TABPHOTO.IMAGE$ / Client: $NOMCLIENT$"));
|
||||
|
||||
Assert.Contains("Client: DigiNova", html);
|
||||
Assert.DoesNotContain("$TABPHOTO.IMAGE$", html);
|
||||
Assert.DoesNotContain("$NOMCLIENT$", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImage_Chemin_NonFuite()
|
||||
{
|
||||
// Une image avec un chemin physique ne doit jamais fuiter dans l'email.
|
||||
var champs = new[] { Champ("$TABPHOTO.IMAGE$", "ChpImage", @"C:\Documents_Demo\photo.jpg") };
|
||||
string html = Rendre(champs, CreerDocx("Photo: $TABPHOTO.IMAGE$"));
|
||||
|
||||
Assert.DoesNotContain("Documents_Demo", html);
|
||||
Assert.DoesNotContain("$TABPHOTO.IMAGE$", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Intégration EmailCreationService (sujet + corps)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task EmailCreationService_SujetTexte_CorpsHtml()
|
||||
{
|
||||
var modeleMailRepository = new FakeModeleMailRepository
|
||||
{
|
||||
Modele = new Omika.Core.Domain.Entities.Documents.ModeleMail { Sujet = "Rapport $NUMDEVIS$" },
|
||||
CorpsDocx = CreerDocxAvecTable(
|
||||
["Nom", "Tel."],
|
||||
["$TABCTSITE.nom$", "$TABCTSITE.telFixe$"])
|
||||
};
|
||||
|
||||
string json = ContactsJson(("Moreau Valérie", "04 13 95 46 73", "06 12 73 59 74"));
|
||||
var rapportChampService = new FakeRapportChampService
|
||||
{
|
||||
Champs =
|
||||
[
|
||||
Champ("$NUMDEVIS$", "ChpSimpleChaine", "391244"),
|
||||
TableauContacts("$TABCONTACTSITE$", "$TABCTSITE", json, "nom", "telFixe")
|
||||
]
|
||||
};
|
||||
|
||||
var service = new EmailCreationService(modeleMailRepository, rapportChampService, new FakeInterventionService());
|
||||
|
||||
EmailModeleApercuDto? apercu = await service.ApercuAsync(
|
||||
new CreateEmailModeleRequest { ModeleId = "1", InterventionId = "guid" });
|
||||
|
||||
Assert.NotNull(apercu);
|
||||
Assert.Equal("Rapport 391244", apercu!.Sujet); // sujet TEXTE
|
||||
Assert.Contains("<table", apercu.Corps); // corps HTML
|
||||
Assert.Contains("Moreau Valérie", apercu.Corps);
|
||||
Assert.DoesNotContain("$TABCTSITE", apercu.Corps);
|
||||
Assert.DoesNotContain("[{", apercu.Corps);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Non-régression BATIK : syntaxe <...> + repli texte
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Batik_SimplePlaceholder_ToujoursRemplace()
|
||||
{
|
||||
var champs = new[] { Champ("<CLIENT.NOM>", "ChpSimpleChaine", "Acme") };
|
||||
string html = Rendre(champs, CreerDocx("Client: <CLIENT.NOM>"));
|
||||
|
||||
Assert.Contains("Client: Acme", html);
|
||||
Assert.DoesNotContain("<CLIENT.NOM>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batik_Tableau_RepliTexte_Conserve()
|
||||
{
|
||||
// Tableau Batik : le jeton PARENT <CONTACT.LISTE> est dans le texte (pas de table Word).
|
||||
var champs = new[]
|
||||
{
|
||||
TableauContacts("<CONTACT.LISTE>", "<CONTACT", "[{\"nom\":\"Dupont\",\"telFixe\":\"04 00\"}]", "nom", "telFixe")
|
||||
};
|
||||
string html = Rendre(champs, CreerDocx("Contacts: <CONTACT.LISTE>"));
|
||||
|
||||
Assert.Contains("Dupont", html);
|
||||
Assert.Contains("04 00", html);
|
||||
Assert.DoesNotContain("<CONTACT", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batik_Sujet_SyntaxeSupportee()
|
||||
{
|
||||
// Le sujet reste TEXTE, et la syntaxe <...> est substituée.
|
||||
var champs = new[] { Champ("<NUMDEVIS>", "ChpSimpleChaine", "391244") };
|
||||
string sujet = SubstituerSujet("<NUMDEVIS> - Rapport", champs);
|
||||
|
||||
Assert.Equal("391244 - Rapport", sujet);
|
||||
}
|
||||
|
||||
private static string SubstituerSujet(string? sujet, IReadOnlyList<RapportChampDto> champs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sujet))
|
||||
{
|
||||
return sujet ?? string.Empty;
|
||||
}
|
||||
|
||||
var remplacements = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var connus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (RapportChampDto champ in champs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(champ.Rubrique)) continue;
|
||||
connus.Add(champ.Rubrique);
|
||||
foreach (RapportChampSousElementDto m in champ.ListeMembre)
|
||||
if (!string.IsNullOrWhiteSpace(m.Rubrique)) connus.Add(m.Rubrique);
|
||||
|
||||
if (champ.ListeMembre is { Count: > 0 })
|
||||
{
|
||||
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterTableauTexte(champ);
|
||||
}
|
||||
else
|
||||
{
|
||||
string? valeur = champ.ListeDonnesMob ?? champ.ListeDonnesBD;
|
||||
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterValeur(valeur, champ.TypeMembre ?? champ.Type, champ.MasqueSaisie);
|
||||
}
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder(sujet);
|
||||
foreach (var paire in remplacements)
|
||||
if (!string.IsNullOrEmpty(paire.Key)) builder.Replace(paire.Key, paire.Value);
|
||||
foreach (string jeton in connus)
|
||||
builder.Replace(jeton, string.Empty);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed class FakeModeleMailRepository : Omika.Core.Interfaces.IModeleMailRepository
|
||||
{
|
||||
public Omika.Core.Domain.Entities.Documents.ModeleMail? Modele { get; set; }
|
||||
public byte[]? CorpsDocx { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<Omika.Core.Domain.Entities.Documents.ModeleMail>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Omika.Core.Domain.Entities.Documents.ModeleMail>>(Modele is null ? [] : [Modele]);
|
||||
|
||||
public Task<Omika.Core.Domain.Entities.Documents.ModeleMail?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Modele);
|
||||
|
||||
public Task<byte[]?> GetCorpsDocxAsync(long modeleMailId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(CorpsDocx);
|
||||
}
|
||||
|
||||
private sealed class FakeRapportChampService : Omika.Core.Interfaces.IRapportChampService
|
||||
{
|
||||
public IReadOnlyList<RapportChampDto> Champs { get; set; } = [];
|
||||
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsAsync(GetRapportChampsRequest request, bool toutesRub = false, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Champs);
|
||||
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsMailAsync(GetRapportChampsRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Champs);
|
||||
}
|
||||
|
||||
private sealed class FakeInterventionService : Omika.Core.Interfaces.IInterventionService
|
||||
{
|
||||
public Task<Omika.Core.Contracts.Common.PagedResult<Omika.Core.Contracts.Interventions.InterventionSummaryDto>> SearchAsync(Omika.Core.Contracts.Interventions.SearchInterventionsRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<Omika.Core.Contracts.Common.PagedResult<Omika.Core.Contracts.Interventions.InterventionDetailsDto>> SearchDetailsAsync(Omika.Core.Contracts.Interventions.SearchInterventionsRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<Omika.Core.Contracts.Interventions.InterventionDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Omika.Core.Contracts.Interventions.InterventionDetailsDto?>(null);
|
||||
|
||||
public Task<Omika.Core.Contracts.Interventions.InterventionDetailsDto> CreateAsync(Omika.Core.Contracts.Interventions.CreateInterventionRequest request, CancellationToken cancellationToken = default, Omika.Core.Domain.AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<Omika.Core.Contracts.Interventions.InterventionDetailsDto?> UpdateAsync(string id, Omika.Core.Contracts.Interventions.UpdateInterventionRequest request, CancellationToken cancellationToken = default, Omika.Core.Domain.AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<Omika.Core.Contracts.Documents.NoteInterneDto>> GetNotesAsync(string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Documents.NoteInterneDto>>([]);
|
||||
|
||||
public Task<IReadOnlyList<Omika.Core.Contracts.Documents.NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Documents.NoteInterneDto>>([]);
|
||||
|
||||
public Task<Omika.Core.Contracts.Documents.NoteInterneDto> AddNoteAsync(string interventionId, string contenu, Omika.Core.Domain.AuditActor? auteur, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<Omika.Core.Contracts.Documents.EmailInterventionDto>> GetEmailsAsync(string interventionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Documents.EmailInterventionDto>>([]);
|
||||
|
||||
public Task<Omika.Core.Contracts.Documents.EmailInterventionDto> AddEmailAsync(string interventionId, Omika.Core.Contracts.Documents.CreateEmailInterventionRequest request, Omika.Core.Domain.AuditActor? auteur, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Omika.Core.Contracts.Documents;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verrouille la machine d'état de persistance du courriel (règles §1, §7-§9) :
|
||||
/// <c>ME_EnvoyerMob</c> est INDÉPENDANT de <c>ME_Envoyer</c> (un envoi WinDev a
|
||||
/// <c>ME_Envoyer=true</c> mais <c>ME_EnvoyerMob=false</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Deux chemins donnent <c>ME_EnvoyerMob=true</c>, tous deux « envoyé depuis le
|
||||
/// mobile » : (1) un envoi SMTP réussi par Omika ; (2) un envoi depuis une
|
||||
/// MESSAGERIE EXTERNE confirmé par l'utilisateur (choix de messagerie
|
||||
/// « Externe » — l'application n'a envoyé aucun courriel elle-même, elle
|
||||
/// enregistre une déclaration).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EmailEnregistrementStateMachineTests
|
||||
{
|
||||
// TEST MOBILE 1 : envoi réussi depuis Omika.
|
||||
[Fact]
|
||||
public void EnvoiReussiDepuisOmika_EnvoyerVrai_EnvoyerMobVrai_BrouillonFaux()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = false,
|
||||
Envoyer = true,
|
||||
EnvoyerMob = true
|
||||
};
|
||||
|
||||
Assert.True(enregistrement.Envoyer);
|
||||
Assert.False(enregistrement.Brouillon);
|
||||
Assert.True(enregistrement.EnvoyerMob);
|
||||
}
|
||||
|
||||
// TEST MOBILE 2 : échec SMTP.
|
||||
[Fact]
|
||||
public void EchecSmtp_EnvoyerFaux_EnvoyerMobFaux_BrouillonVrai()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = true,
|
||||
Envoyer = false,
|
||||
EnvoyerMob = false
|
||||
};
|
||||
|
||||
Assert.False(enregistrement.Envoyer);
|
||||
Assert.True(enregistrement.Brouillon);
|
||||
Assert.False(enregistrement.EnvoyerMob);
|
||||
}
|
||||
|
||||
// TEST MOBILE 3 : enregistrement sans envoi (brouillon).
|
||||
[Fact]
|
||||
public void EnregistrementSansEnvoi_EnvoyerFaux_EnvoyerMobFaux_BrouillonVrai()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = true,
|
||||
Envoyer = false,
|
||||
EnvoyerMob = false
|
||||
};
|
||||
|
||||
Assert.False(enregistrement.Envoyer);
|
||||
Assert.True(enregistrement.Brouillon);
|
||||
Assert.False(enregistrement.EnvoyerMob);
|
||||
}
|
||||
|
||||
// TEST MOBILE 4 : un message envoyé par WinDev ne force jamais EnvoyerMob=true.
|
||||
[Fact]
|
||||
public void MessageEnvoyeParWinDev_EnvoyerVraiMaisEnvoyerMobFaux()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = false,
|
||||
Envoyer = true,
|
||||
EnvoyerMob = false
|
||||
};
|
||||
|
||||
// ME_EnvoyerMob est un champ DISTINCT de ME_Envoyer : il peut rester faux
|
||||
// alors que le message a été envoyé par WinDev.
|
||||
Assert.True(enregistrement.Envoyer);
|
||||
Assert.False(enregistrement.EnvoyerMob);
|
||||
}
|
||||
|
||||
// TEST MOBILE 4 (invariant) : EnvoyerMob n'est PAS dérivé de Envoyer.
|
||||
[Fact]
|
||||
public void EnvoyerMob_EstUnChampIndependantDeEnvoyer()
|
||||
{
|
||||
// Le record expose les deux drapeaux séparément : aucune dérivation
|
||||
// EnvoyerMob = Envoyer n'est codée dans le contrat.
|
||||
Assert.False(new EmailEnregistrement().EnvoyerMob);
|
||||
Assert.False(new EmailEnregistrement().Envoyer);
|
||||
}
|
||||
|
||||
// TEST MOBILE 5 : envoi déclaré depuis une MESSAGERIE EXTERNE (choix
|
||||
// « Externe » + confirmation de l'utilisateur). Omika n'a envoyé aucun
|
||||
// courriel, aucun expéditeur n'est connu, mais le message est bien « envoyé
|
||||
// par le mobile » : EnvoyerMob = vrai, comme un envoi SMTP réussi.
|
||||
[Fact]
|
||||
public void EnvoiDeclareDepuisMessagerieExterne_EnvoyerMobVrai_SansExpediteur()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = false,
|
||||
Envoyer = true,
|
||||
EnvoyerMob = true,
|
||||
AdrExp = null
|
||||
};
|
||||
|
||||
Assert.True(enregistrement.EnvoyerMob);
|
||||
Assert.False(enregistrement.Brouillon);
|
||||
Assert.Null(enregistrement.AdrExp);
|
||||
}
|
||||
|
||||
// TEST MOBILE 5 (repli) : réponse « Non » à la confirmation — l'utilisateur
|
||||
// n'a pas envoyé le courriel depuis sa messagerie : il reste un BROUILLON.
|
||||
[Fact]
|
||||
public void ReponseNonALaConfirmationExterne_ResteBrouillon()
|
||||
{
|
||||
var enregistrement = new EmailEnregistrement
|
||||
{
|
||||
Brouillon = true,
|
||||
Envoyer = false,
|
||||
EnvoyerMob = false
|
||||
};
|
||||
|
||||
Assert.True(enregistrement.Brouillon);
|
||||
Assert.False(enregistrement.Envoyer);
|
||||
Assert.False(enregistrement.EnvoyerMob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Text;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de la préparation pure d'une tentative d'envoi : composition du corps
|
||||
/// (BASE BODY + signature), décodage de signature, fusion CC/CCI effectifs
|
||||
/// (règles §4, §8-§11). Aucun réseau ni persistance.
|
||||
/// </summary>
|
||||
public class EmailEnvoiPreparationTests
|
||||
{
|
||||
// TEST 4 / 5 : la signature n'est ajoutée qu'au corps d'envoi, jamais au corps de base.
|
||||
[Fact]
|
||||
public void ComposerCorpsEnvoi_SansSignature_RetourneCorpsDeBase()
|
||||
{
|
||||
Assert.Equal("BODY", EmailEnvoiPreparation.ComposerCorpsEnvoi("BODY", null));
|
||||
Assert.Equal("BODY", EmailEnvoiPreparation.ComposerCorpsEnvoi("BODY", " "));
|
||||
Assert.Equal("BODY", EmailEnvoiPreparation.ComposerCorpsEnvoi("BODY", string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposerCorpsEnvoi_AvecSignature_AppendLaSignature()
|
||||
{
|
||||
Assert.Equal("BODY<div>sig</div>", EmailEnvoiPreparation.ComposerCorpsEnvoi("BODY", "<div>sig</div>"));
|
||||
}
|
||||
|
||||
// TEST 6 : changer de configuration (A → B) n'accumule jamais les signatures.
|
||||
[Fact]
|
||||
public void ComposerCorpsEnvoi_ConfigurationsDifferentes_SignaturesIndependantes()
|
||||
{
|
||||
const string baseBody = "BODY";
|
||||
Assert.Equal("BODY<A>", EmailEnvoiPreparation.ComposerCorpsEnvoi(baseBody, "<A>"));
|
||||
Assert.Equal("BODY<B>", EmailEnvoiPreparation.ComposerCorpsEnvoi(baseBody, "<B>"));
|
||||
Assert.Equal("BODY", baseBody); // le corps de base n'est jamais muté.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecoderSignature_Base64Html_ExtraitBody()
|
||||
{
|
||||
string html = "<html><head><style>a{}</style></head><body><p>Signature</p></body></html>";
|
||||
string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(html));
|
||||
|
||||
Assert.Equal("<p>Signature</p>", EmailEnvoiPreparation.DecoderSignature(b64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecoderSignature_VideOuAbsente_RetourneNull()
|
||||
{
|
||||
Assert.Null(EmailEnvoiPreparation.DecoderSignature(null));
|
||||
Assert.Null(EmailEnvoiPreparation.DecoderSignature(" "));
|
||||
}
|
||||
|
||||
// TEST 7 : tabCC_CopieEmail ajouté au CC SMTP (fusion).
|
||||
[Fact]
|
||||
public void FusionnerCc_UserEtConfig_Fusionne()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCc(
|
||||
"client1@example.test",
|
||||
["archive@example.test", "supervision@example.test"]);
|
||||
|
||||
Assert.Equal(["client1@example.test", "archive@example.test", "supervision@example.test"], result);
|
||||
}
|
||||
|
||||
// TEST 8 : en cas d'échec, seul le CC utilisateur est conservé (aucun CC config).
|
||||
[Fact]
|
||||
public void FusionnerCc_UserSeul_SansConfig()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCc("client1@example.test", null);
|
||||
Assert.Equal(["client1@example.test"], result);
|
||||
}
|
||||
|
||||
// TEST 11 : fusion + dédoublonnage insensible à la casse + vides retirés.
|
||||
[Fact]
|
||||
public void FusionnerCc_DedoublonneEtNettoie()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCc(
|
||||
"A@x.com; b@x.com; ;a@X.com",
|
||||
["a@x.com", "", "C@x.com"]);
|
||||
|
||||
Assert.Equal(["A@x.com", "b@x.com", "C@x.com"], result);
|
||||
}
|
||||
|
||||
// TEST 9 : bEnCopieCachee=true → adresse expéditrice ajoutée au CCI.
|
||||
[Fact]
|
||||
public void FusionnerCci_EnCopieCachee_AjouteSender()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCci("user@x.com", true, "sender@x.com");
|
||||
Assert.Equal(["user@x.com", "sender@x.com"], result);
|
||||
}
|
||||
|
||||
// TEST 10 : bEnCopieCachee=false → aucun auto-CCI.
|
||||
[Fact]
|
||||
public void FusionnerCci_SansEnCopieCachee_NeAjoutePasSender()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCci("user@x.com", false, "sender@x.com");
|
||||
Assert.Equal(["user@x.com"], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusionnerCci_DedoublonneSender()
|
||||
{
|
||||
IReadOnlyList<string> result = EmailEnvoiPreparation.FusionnerCci("sender@x.com", true, "sender@x.com");
|
||||
Assert.Equal(["sender@x.com"], result);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ME_IDDocPJ — construction des PKID HFSQL (règles §2, §6)
|
||||
// =====================================================================
|
||||
|
||||
// TEST PJ ID 1 : aucune pièce jointe → chaîne vide (jamais "0").
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_AucunePiece_RetourneChaineVide()
|
||||
{
|
||||
Assert.Equal(string.Empty, EmailEnvoiPreparation.ConstruireIdDocPj([]));
|
||||
Assert.Equal(string.Empty, EmailEnvoiPreparation.ConstruireIdDocPj(null!));
|
||||
}
|
||||
|
||||
// TEST PJ ID 2 : une seule PJ → son PKID, sans séparateur.
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_UnePiece_RetourneLePk()
|
||||
{
|
||||
Assert.Equal("281474976850215", EmailEnvoiPreparation.ConstruireIdDocPj([281474976850215L]));
|
||||
}
|
||||
|
||||
// TEST PJ ID 3 : plusieurs PJ → séparées par « ; », ordre conservé.
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_TroisPieces_SeparateurPointVirgule()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireIdDocPj(
|
||||
[281474976850215L, 281474976850216L, 281474976850217L]);
|
||||
|
||||
Assert.Equal("281474976850215;281474976850216;281474976850217", result);
|
||||
}
|
||||
|
||||
// TEST PJ ID 4 : jamais de virgule.
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_NeContientPasDeVirgule()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireIdDocPj(
|
||||
[281474976850215L, 281474976850216L]);
|
||||
|
||||
Assert.DoesNotContain(",", result);
|
||||
}
|
||||
|
||||
// TEST PJ ID 5 : jamais de GUID (uniquement des chiffres et « ; »).
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_NeContientPasDeGuid()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireIdDocPj(
|
||||
[281474976850215L, 281474976850216L]);
|
||||
|
||||
Assert.Matches("^[0-9;]+$", result);
|
||||
Assert.DoesNotContain("-", result);
|
||||
}
|
||||
|
||||
// TEST PJ ID 6 : aucun séparateur final.
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_AucunSeparateurFinal()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireIdDocPj(
|
||||
[281474976850215L, 281474976850216L]);
|
||||
|
||||
Assert.False(result.EndsWith(';'));
|
||||
}
|
||||
|
||||
// Règles §6 : IDs <= 0 ignorés, doublons supprimés.
|
||||
[Fact]
|
||||
public void ConstruireIdDocPj_IgnoreIdsNonPositifs_EtDedoublonne()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireIdDocPj(
|
||||
[0L, -5L, 281474976850215L, 281474976850215L, 281474976850216L]);
|
||||
|
||||
Assert.Equal("281474976850215;281474976850216", result);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ME_PieceJointes — chemins complets serveur (règles §5)
|
||||
// =====================================================================
|
||||
|
||||
// TEST PATH 1 : un seul document → son chemin complet serveur.
|
||||
[Fact]
|
||||
public void ConstruireCheminsPj_UnChemin_RetourneLeChemin()
|
||||
{
|
||||
Assert.Equal(@"D:\Documents\Client\Inter\photo.jpg",
|
||||
EmailEnvoiPreparation.ConstruireCheminsPj([@"D:\Documents\Client\Inter\photo.jpg"]));
|
||||
}
|
||||
|
||||
// TEST PATH 2 : deux documents → PATH1;PATH2.
|
||||
[Fact]
|
||||
public void ConstruireCheminsPj_DeuxChemins_SeparateurPointVirgule()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireCheminsPj(
|
||||
[@"D:\Documents\a.jpg", @"D:\Documents\b.pdf"]);
|
||||
|
||||
Assert.Equal(@"D:\Documents\a.jpg;D:\Documents\b.pdf", result);
|
||||
}
|
||||
|
||||
// TEST PATH 5 : cardinalité identique entre ME_IDDocPJ et ME_PieceJointes.
|
||||
[Fact]
|
||||
public void IdDocPj_EtChemins_ComptesIdentiques()
|
||||
{
|
||||
long[] pks = [101, 205, 312];
|
||||
string[] chemins = [@"D:\a.jpg", @"D:\b.pdf", @"D:\c.txt"];
|
||||
|
||||
string ids = EmailEnvoiPreparation.ConstruireIdDocPj(pks);
|
||||
string paths = EmailEnvoiPreparation.ConstruireCheminsPj(chemins);
|
||||
|
||||
Assert.Equal("101;205;312", ids);
|
||||
Assert.Equal(3, ids.Split(';').Length);
|
||||
Assert.Equal(3, paths.Split(';').Length);
|
||||
}
|
||||
|
||||
// Règles §5 : vides retirés, doublons supprimés (insensible à la casse),
|
||||
// aucun séparateur final.
|
||||
[Fact]
|
||||
public void ConstruireCheminsPj_RetireVides_Dedoublonne_SansSeparateurFinal()
|
||||
{
|
||||
string result = EmailEnvoiPreparation.ConstruireCheminsPj(
|
||||
[@"D:\a.jpg", "", " ", @"d:\A.JPG", @"D:\b.pdf"]);
|
||||
|
||||
Assert.Equal(@"D:\a.jpg;D:\b.pdf", result);
|
||||
Assert.False(result.EndsWith(';'));
|
||||
}
|
||||
|
||||
// Aucune pièce jointe → chaîne vide (jamais null ni "0").
|
||||
[Fact]
|
||||
public void ConstruireCheminsPj_AucunChemin_RetourneChaineVide()
|
||||
{
|
||||
Assert.Equal(string.Empty, EmailEnvoiPreparation.ConstruireCheminsPj([]));
|
||||
Assert.Equal(string.Empty, EmailEnvoiPreparation.ConstruireCheminsPj(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Omika.App.Controls;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de COMPATIBILITÉ du corps HTML des courriels WinDev ↔ Omika :
|
||||
/// <list type="bullet">
|
||||
/// <item><b>WinDev → Omika</b> (lecture) : le CSS/<head>/<style> ne sont
|
||||
/// jamais rendus comme texte ; les tables (tbody), le bloc photo imbriqué et les
|
||||
/// tableaux de contacts sont lus dans l'ordre.</item>
|
||||
/// <item><b>Omika → WinDev</b> (écriture) : le HTML persisté est conservateur
|
||||
/// (<table>/<tr>/<td>/<p>/<img>), la photo est empilée
|
||||
/// (image puis date), les contacts sont en vraies cellules, sans flex/grid.</item>
|
||||
/// <item><b>Roundtrip</b> : relecture du HTML Omika sans perte structurelle.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class EmailHtmlCompatibiliteTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static readonly byte[] OctetsJpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers — lecture (parser)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static IReadOnlyList<HtmlBlock> Parser(string html) => EmailHtmlParser.Parser(html);
|
||||
|
||||
private static IEnumerable<HtmlInline> InlinesTous(IReadOnlyList<HtmlBlock> blocs)
|
||||
{
|
||||
foreach (HtmlBlock bloc in blocs)
|
||||
{
|
||||
switch (bloc)
|
||||
{
|
||||
case HtmlParagraph p:
|
||||
foreach (HtmlInline i in p.Inlines) yield return i;
|
||||
break;
|
||||
case HtmlTable t:
|
||||
foreach (HtmlRow row in t.Rows)
|
||||
foreach (HtmlCell cell in row.Cells)
|
||||
foreach (HtmlInline i in cell.Inlines)
|
||||
yield return i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> Textes(IReadOnlyList<HtmlBlock> blocs)
|
||||
=> InlinesTous(blocs).OfType<HtmlRun>().Select(r => r.Text).ToList();
|
||||
|
||||
private static string ToutLeTexte(string html) => string.Join("\n", Textes(Parser(html)));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers — écriture (DOCX → HTML)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>DOCX : table 1 colonne / 2 lignes — ligne 2 = 3 paragraphes (image, date, note).</summary>
|
||||
private static byte[] CreerDocxPhotoBloc(string titre = "Photos")
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
var table = new Table();
|
||||
table.Append(new TableRow(new TableCell(new Paragraph(new Run(new Text(titre))))));
|
||||
var cellule = new TableCell();
|
||||
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.IMAGE$"))));
|
||||
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.DATEIMAGE$"))));
|
||||
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.NOTEIMAGE$"))));
|
||||
table.Append(new TableRow(cellule));
|
||||
body.Append(table);
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>DOCX : table contacts 5 colonnes (en-tête + 1 ligne de données).</summary>
|
||||
private static byte[] CreerDocxContacts()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
var table = new Table();
|
||||
table.Append(new TableRow(
|
||||
Cell("$TABCTSITE.NOM$"), Cell("$TABCTSITE.TELFIXE$"), Cell("$TABCTSITE.TELFAXE$"),
|
||||
Cell("$TABCTSITE.ROLE$"), Cell("$TABCTSITE.ETAGE$")));
|
||||
table.Append(new TableRow(
|
||||
Cell("Dupont Jean"), Cell("04 00 00 00 00"), Cell("06 00 00 00 00"),
|
||||
Cell("Occupant"), Cell("4")));
|
||||
body.Append(table);
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
|
||||
static TableCell Cell(string texte) => new(new Paragraph(new Run(new Text(texte))));
|
||||
}
|
||||
|
||||
/// <summary>DOCX : bloc photo (image/date/note empilés) + tableau contacts (5 colonnes).</summary>
|
||||
private static byte[] CreerDocxPhotoEtContacts()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
|
||||
// Bloc photo (1 colonne / 2 lignes ; ligne 2 = 3 paragraphes empilés).
|
||||
var photo = new Table();
|
||||
photo.Append(new TableRow(new TableCell(new Paragraph(new Run(new Text("Photos"))))));
|
||||
var cellulePhoto = new TableCell();
|
||||
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.IMAGE$"))));
|
||||
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.DATEIMAGE$"))));
|
||||
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.NOTEIMAGE$"))));
|
||||
photo.Append(new TableRow(cellulePhoto));
|
||||
body.Append(photo);
|
||||
|
||||
// Contacts (5 colonnes : en-têtes littéraux + 1 ligne de données déjà résolue).
|
||||
var contacts = new Table();
|
||||
contacts.Append(new TableRow(
|
||||
new TableCell(new Paragraph(new Run(new Text("Nom - Prénom")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("Tel. 1")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("Tel. 2")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("Rôle")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("Étage"))))));
|
||||
contacts.Append(new TableRow(
|
||||
new TableCell(new Paragraph(new Run(new Text("Dupont Jean")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("04 00 00 00 00")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("06 00 00 00 00")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("Occupant")))),
|
||||
new TableCell(new Paragraph(new Run(new Text("4"))))));
|
||||
body.Append(contacts);
|
||||
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static RapportChampDto TableauPhotos(string json)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = "$TABPHOTO$",
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre =
|
||||
[
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.IMAGE$", Type = "ChpImage", TypeMembre = "ChpImage", MembreMob = "cheminComplet" },
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.DATEIMAGE$", Type = "ChpSimpleDate", TypeMembre = "ChpSimpleDate", MembreMob = "saDate" },
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.NOTEIMAGE$", Type = "ChpSimpleChaine", TypeMembre = "ChpSimpleChaine", MembreMob = "saNotes" }
|
||||
]
|
||||
};
|
||||
|
||||
private static string RendreCorps(IReadOnlyList<RapportChampDto> champs, byte[] docx, out IReadOnlyList<EmailInlineImage> images)
|
||||
{
|
||||
var liste = new List<EmailInlineImage>();
|
||||
byte[] rendu = EmailDocxRenderer.Rendre(docx, champs, liste);
|
||||
images = liste;
|
||||
return EmailImageInliner.Inliner(DocxHtmlConverter.Convertir(rendu), liste);
|
||||
}
|
||||
|
||||
private static string JsonUnePhoto() => JsonSerializer.Serialize(new[]
|
||||
{
|
||||
// Les propriétés JSON suivent la convention camelCase (après normalisation
|
||||
// MembreMob : saDate → date, saNotes → notes).
|
||||
new { cheminComplet = Convert.ToBase64String(OctetsJpeg), date = "30/01/2026", notes = "Note photo" }
|
||||
}, Json);
|
||||
|
||||
// ==================================================================
|
||||
// §13 — WINDEV → OMIKA (lecture)
|
||||
// ==================================================================
|
||||
|
||||
// TEST 1 : <head><style> jamais rendu comme texte
|
||||
[Fact]
|
||||
public void WinDev_CssJamaisRenduCommeTexte()
|
||||
{
|
||||
string texte = ToutLeTexte(EmailHtmlFixture.WinDevHtmlComplet());
|
||||
|
||||
Assert.DoesNotContain("font-family", texte);
|
||||
Assert.DoesNotContain("Calibri", texte);
|
||||
Assert.DoesNotContain("margin-bottom", texte);
|
||||
Assert.DoesNotContain(".Normal", texte);
|
||||
Assert.DoesNotContain(".TableauNormal", texte);
|
||||
Assert.DoesNotContain(".xtab", texte);
|
||||
Assert.DoesNotContain("{", texte);
|
||||
Assert.DoesNotContain("}", texte);
|
||||
}
|
||||
|
||||
// TEST 2 : classes .Normal/.TableauNormal/.xtab → contenu métier visible, CSS non visible
|
||||
[Fact]
|
||||
public void WinDev_ContenuMetierVisible_CssSourceInvisible()
|
||||
{
|
||||
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
|
||||
List<string> textes = Textes(blocs);
|
||||
string tout = string.Join("\n", textes);
|
||||
|
||||
Assert.Contains("Rapport d'intervention", tout);
|
||||
Assert.Contains("Nom du client", tout);
|
||||
Assert.Contains("Photos", tout);
|
||||
Assert.Contains("Dupont Jean", tout);
|
||||
Assert.Contains("Occupant", tout);
|
||||
|
||||
Assert.DoesNotContain("Grilledutableau", tout);
|
||||
Assert.DoesNotContain("padding-left", tout);
|
||||
Assert.DoesNotContain("display:block", tout);
|
||||
}
|
||||
|
||||
// TEST 3 : table photo WinDev → image + date, date située après/sous l'image
|
||||
[Fact]
|
||||
public void WinDev_TablePhoto_ImagePuisDate()
|
||||
{
|
||||
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
|
||||
|
||||
int idxImage = inlines.FindIndex(i => i is HtmlImage);
|
||||
int idxDate = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("30/01/2026"));
|
||||
int idxNote = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("Note photo"));
|
||||
|
||||
Assert.True(idxImage >= 0, "image absente");
|
||||
Assert.True(idxDate >= 0, "date absente");
|
||||
Assert.True(idxNote >= 0, "note absente");
|
||||
Assert.True(idxImage < idxDate, "la date doit suivre l'image dans la structure");
|
||||
Assert.True(idxDate < idxNote, "la note doit suivre la date dans la structure");
|
||||
}
|
||||
|
||||
// TEST 4 : table contacts WinDev → toutes les cellules dans le bon ordre
|
||||
[Fact]
|
||||
public void WinDev_TableContacts_CellulesDansOrdre()
|
||||
{
|
||||
List<string> textes = Textes(Parser(EmailHtmlFixture.WinDevHtmlComplet()));
|
||||
|
||||
string[] attendus =
|
||||
[
|
||||
"Nom - Prénom", "Tel. 1", "Tel. 2", "Rôle", "Étage",
|
||||
"Dupont Jean", "04 00 00 00 00", "06 00 00 00 00", "Occupant", "4"
|
||||
];
|
||||
|
||||
foreach (string attendu in attendus)
|
||||
{
|
||||
Assert.Contains(attendu, textes);
|
||||
}
|
||||
|
||||
// L'ordre des en-têtes et des cellules est préservé.
|
||||
Assert.True(textes.IndexOf("Nom - Prénom") < textes.IndexOf("Tel. 1"));
|
||||
Assert.True(textes.IndexOf("Tel. 1") < textes.IndexOf("Tel. 2"));
|
||||
Assert.True(textes.IndexOf("Dupont Jean") < textes.IndexOf("Occupant"));
|
||||
}
|
||||
|
||||
// TEST 5 : aucune donnée de <style>/<script> dans le texte final
|
||||
[Fact]
|
||||
public void WinDev_AucuneDonneeStyleScript_DansTexteFinal()
|
||||
{
|
||||
List<string> textes = Textes(Parser(EmailHtmlFixture.WinDevHtmlComplet()));
|
||||
|
||||
Assert.DoesNotContain(textes, t => t.Contains("td *"));
|
||||
Assert.DoesNotContain(textes, t => t.Contains("text/css"));
|
||||
Assert.DoesNotContain(textes, t => t.Contains("PCSOFT"));
|
||||
Assert.DoesNotContain(textes, t => t.Contains("<style"));
|
||||
Assert.DoesNotContain(textes, t => t.Contains("<script"));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// §14 — OMIKA → WINDEV (écriture)
|
||||
// ==================================================================
|
||||
|
||||
// TEST 6 : photo → image sur sa ligne, date sur la ligne suivante (paragraphes séparés)
|
||||
[Fact]
|
||||
public void Omika_Photo_ImagePuisDate_SurParagraphesSepares()
|
||||
{
|
||||
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Single(images);
|
||||
|
||||
// L'image, la date et la note sont chacune dans leur PROPRE <p> : jamais
|
||||
// « <img/>30/01/2026 » dans le même bloc.
|
||||
Assert.Contains("<p><img", html);
|
||||
Assert.Contains("<p>30/01/2026</p>", html);
|
||||
Assert.Contains("<p>Note photo</p>", html);
|
||||
}
|
||||
|
||||
// TEST 7 : contacts → vraies lignes/cellules HTML
|
||||
[Fact]
|
||||
public void Omika_Contacts_VraiesCellules()
|
||||
{
|
||||
string html = RendreCorps(
|
||||
[TableauPhotos(JsonUnePhoto())],
|
||||
CreerDocxContacts(),
|
||||
out _);
|
||||
|
||||
Assert.Contains("<table", html);
|
||||
Assert.Contains("<tr>", html);
|
||||
Assert.Contains("<td ", html);
|
||||
Assert.Contains("Dupont Jean", html);
|
||||
Assert.Contains("04 00 00 00 00", html);
|
||||
Assert.Contains("Occupant", html);
|
||||
}
|
||||
|
||||
// TEST 8 : le HTML de stockage ne dépend pas de display:flex / display:grid
|
||||
[Fact]
|
||||
public void Omika_HtmlStockage_SansFlexGrid()
|
||||
{
|
||||
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out _);
|
||||
|
||||
Assert.DoesNotContain("display:flex", html);
|
||||
Assert.DoesNotContain("display: flex", html);
|
||||
Assert.DoesNotContain("display:grid", html);
|
||||
Assert.DoesNotContain("display: grid", html);
|
||||
Assert.DoesNotContain("color-mix", html);
|
||||
}
|
||||
|
||||
// TEST 9 : toutes les données restent présentes (photo + contacts)
|
||||
[Fact]
|
||||
public void Omika_ToutesDonneesPresentes()
|
||||
{
|
||||
string html = RendreCorps(
|
||||
[TableauPhotos(JsonUnePhoto())],
|
||||
CreerDocxPhotoEtContacts(),
|
||||
out _);
|
||||
|
||||
foreach (string attendu in new[]
|
||||
{
|
||||
"Photos", "30/01/2026", "Note photo",
|
||||
"Dupont Jean", "04 00 00 00 00", "06 00 00 00 00", "Occupant", "4"
|
||||
})
|
||||
{
|
||||
Assert.Contains(attendu, html);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain("$TABPHOTO", html);
|
||||
Assert.DoesNotContain("$TABCTSITE", html);
|
||||
}
|
||||
|
||||
// TEST 10 : les images restent exploitables par le pipeline SMTP (data URI → CID)
|
||||
[Fact]
|
||||
public void Omika_Images_ExploitablesParPipelineSmtp()
|
||||
{
|
||||
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Single(images);
|
||||
Assert.Contains("<img src=\"data:image/jpeg;base64,", html);
|
||||
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(html);
|
||||
Assert.Contains("cid:", htmlSmtp);
|
||||
Assert.Single(ressources);
|
||||
Assert.DoesNotContain("base64", htmlSmtp);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// §15 — ROUNDTRIP STRUCTUREL (Omika → parser → relecture)
|
||||
// ==================================================================
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_Omika_PasDePerteStructurelle()
|
||||
{
|
||||
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out _);
|
||||
|
||||
// Relecture par le parser Omika (même chemin que l'aperçu).
|
||||
IReadOnlyList<HtmlBlock> blocs = Parser(html);
|
||||
List<string> textes = Textes(blocs);
|
||||
List<HtmlInline> inlines = InlinesTous(blocs).ToList();
|
||||
|
||||
Assert.Contains("Photos", textes); // TEXTE conservé
|
||||
Assert.Contains("30/01/2026", textes); // DATE conservée
|
||||
Assert.Contains("Note photo", textes); // TEXTE conservé
|
||||
Assert.Contains(blocs, b => b is HtmlTable); // TABLE conservée
|
||||
Assert.Contains(inlines, i => i is HtmlImage); // IMAGE conservée
|
||||
|
||||
// Aucun CSS/token ne fuite.
|
||||
string tout = string.Join("\n", textes);
|
||||
Assert.DoesNotContain("$TABPHOTO", tout);
|
||||
Assert.DoesNotContain("font-family", tout);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// §7 — FINITION TABLEAUX (bordures compatibles + espacement)
|
||||
// ==================================================================
|
||||
|
||||
// TEST TABLE A : HTML Omika généré → bordures compatibles + structure cellule.
|
||||
[Fact]
|
||||
public void TableA_Omika_TableBordureCompatible()
|
||||
{
|
||||
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoEtContacts(), out _);
|
||||
|
||||
// Attributs HTML historiques (indépendants du <style> global).
|
||||
Assert.Contains("cellspacing=\"0\"", html);
|
||||
Assert.Contains("cellpadding=\"0\"", html);
|
||||
// Bordure inline fine sur chaque cellule (style simple, pas de CSS global).
|
||||
Assert.Contains("border:1pt solid #000000", html);
|
||||
// Structure réelle table/tr/td conservée.
|
||||
Assert.Contains("<table", html);
|
||||
Assert.Contains("<tr>", html);
|
||||
Assert.Contains("<td ", html);
|
||||
Assert.DoesNotContain("display:flex", html);
|
||||
Assert.DoesNotContain("display:grid", html);
|
||||
}
|
||||
|
||||
// TEST TABLE B : fixture WinDev → aucune hauteur fixe de table conservée.
|
||||
[Fact]
|
||||
public void TableB_WinDev_AucuneHauteurFixeExcessive()
|
||||
{
|
||||
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
|
||||
List<string> textes = Textes(blocs);
|
||||
|
||||
// Le modèle structuré n'expose AUCUNE hauteur (table/tr/td) : le
|
||||
// « height:153.1pt » de WinDev n'est pas traduit en Height fixe Avalonia.
|
||||
Assert.DoesNotContain(textes, t => t.Contains("153.1pt"));
|
||||
Assert.DoesNotContain(textes, t => t.Contains("height"));
|
||||
|
||||
// Dimensionnement au contenu : photo / date / note toujours présents.
|
||||
Assert.Contains("Photos", textes);
|
||||
Assert.Contains("30/01/2026", textes);
|
||||
Assert.Contains("Note photo", textes);
|
||||
}
|
||||
|
||||
// TEST TABLE C : image WinDev → dimensions width/height préservées.
|
||||
[Fact]
|
||||
public void TableC_WinDev_ImageTaillePreservee()
|
||||
{
|
||||
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
|
||||
|
||||
HtmlImage image = Assert.IsType<HtmlImage>(inlines.Single(i => i is HtmlImage));
|
||||
Assert.Equal(305, image.Width);
|
||||
Assert.Equal(206, image.Height);
|
||||
}
|
||||
|
||||
// TEST TABLE D : paragraphes de cellule — ordre conservé, sans espacement excessif.
|
||||
[Fact]
|
||||
public void TableD_WinDev_ParagraphesCellule_SansEspacementExcessif()
|
||||
{
|
||||
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
|
||||
|
||||
int idxImage = inlines.FindIndex(i => i is HtmlImage);
|
||||
int idxDate = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("30/01/2026"));
|
||||
int idxNote = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("Note photo"));
|
||||
|
||||
Assert.True(idxImage >= 0, "image absente");
|
||||
Assert.True(idxDate >= 0, "date absente");
|
||||
Assert.True(idxNote >= 0, "note absente");
|
||||
Assert.True(idxImage < idxDate && idxDate < idxNote, "ordre image → date → note non respecté");
|
||||
|
||||
// Entre image et date, puis entre date et note : UN SEUL saut de ligne
|
||||
// (aucun saut doublé → aucune ligne vide cumulée).
|
||||
IReadOnlyList<HtmlInline> entreImageDate = inlines.Skip(idxImage + 1).Take(idxDate - idxImage - 1).ToList();
|
||||
Assert.Single(entreImageDate);
|
||||
Assert.IsType<HtmlLineBreak>(entreImageDate[0]);
|
||||
|
||||
IReadOnlyList<HtmlInline> entreDateNote = inlines.Skip(idxDate + 1).Take(idxNote - idxDate - 1).ToList();
|
||||
Assert.Single(entreDateNote);
|
||||
Assert.IsType<HtmlLineBreak>(entreDateNote[0]);
|
||||
}
|
||||
|
||||
// TEST TABLE E : aucune régression (CSS invisible, contenu/photo/date présents).
|
||||
[Fact]
|
||||
public void TableE_AucuneRegression()
|
||||
{
|
||||
string texte = ToutLeTexte(EmailHtmlFixture.WinDevHtmlComplet());
|
||||
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
|
||||
|
||||
// WINDEV CSS TEXT = NOT VISIBLE
|
||||
Assert.DoesNotContain("font-family", texte);
|
||||
Assert.DoesNotContain(".Normal", texte);
|
||||
Assert.DoesNotContain("Calibri", texte);
|
||||
|
||||
// TABLE CONTENT = PRESENT
|
||||
Assert.Contains("Nom - Prénom", texte);
|
||||
Assert.Contains("Dupont Jean", texte);
|
||||
Assert.Contains(blocs, b => b is HtmlTable);
|
||||
|
||||
// PHOTO = PRESENT, DATE = PRESENT
|
||||
Assert.Contains(InlinesTous(blocs), i => i is HtmlImage);
|
||||
Assert.Contains("30/01/2026", texte);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fixtures HTML SANITISÉES reproduisant fidèlement la STRUCTURE réelle des corps
|
||||
/// de courriel (balises, classes, attributs, imbrications), sans aucune donnée
|
||||
/// privée (aucun nom/adresse/téléphone/email réel). Les textes sont des
|
||||
/// placeholders génériques. Le CSS est le CSS générique WINDEV (non privé).
|
||||
/// </summary>
|
||||
internal static class EmailHtmlFixture
|
||||
{
|
||||
/// <summary>PNG minimal (magic bytes) encodé en Base64 (décodable, jamais rendu).</summary>
|
||||
public const string PngBase64 = "iVBORw0KGgo=";
|
||||
|
||||
/// <summary>
|
||||
/// HTML complet WINDEV (<c>DocVersHTML</c>) : <c><!DOCTYPE></c>,
|
||||
/// <c><head></c> + <c><style></c> (classes .Normal/.TableauNormal/.xtab/.xcenter/.xleft/.xright),
|
||||
/// <c><body></c> avec un bloc photo en TABLE IMBRIQUÉE (ligne image / ligne
|
||||
/// date / ligne note) et deux tableaux de contacts en vraies cellules.
|
||||
/// </summary>
|
||||
public static string WinDevHtmlComplet()
|
||||
{
|
||||
string png = $"data:image/png;base64,{PngBase64}";
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="generator" content="PCSOFT">
|
||||
<title></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<style type="text/css">
|
||||
td * {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.Normal {
|
||||
font-family:Calibri;
|
||||
font-size:11pt;
|
||||
line-height:108%;
|
||||
margin-top:0.0pt;
|
||||
margin-right:0.0pt;
|
||||
margin-bottom:8.0pt;
|
||||
margin-left:0.0pt;
|
||||
text-indent:0.0pt;
|
||||
}
|
||||
|
||||
.TableauNormal {
|
||||
font-family:Calibri;
|
||||
font-size:11pt;
|
||||
}
|
||||
|
||||
.xtab {padding-left: 1em;}
|
||||
.xcenter {margin:0 auto; display:block;}
|
||||
.xleft {display:block;}
|
||||
.xright {margin:0px auto; margin-right:0; display:block;}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p class="Normal"><span style="font-family:Arial;font-size:12pt;color:#000000;">Rapport d'intervention</span></p>
|
||||
<p class="Normal"><span style="font-family:Arial;font-size:12pt;color:#000000;">Client : </span><span style="font-family:Arial;font-size:12pt;font-weight:bold;color:#000000;">Nom du client</span></p>
|
||||
<p class="Normal"><span style="font-family:Arial;font-size:12pt;color:#000000;">Intervention du 15/01/2026</span></p>
|
||||
|
||||
<table class="TableauNormal Grilledutableau" cellspacing="0" cellpadding="0" width="auto" style="table-layout:fixed;width:239.651pt;border-collapse:collapse;">
|
||||
<tbody>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:239.7pt;padding:0.0pt 5.4pt 0.0pt 5.4pt">
|
||||
<p class="Normal" style="margin-bottom:0.0pt">Photos</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:239.7pt;padding:0.0pt 5.4pt 0.0pt 5.4pt">
|
||||
<table cellspacing="0" cellpadding="0" width="auto"><tbody>
|
||||
<tr style="height:153.1pt">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:228.8pt;padding:0.0pt 5.4pt 0.0pt 5.4pt">
|
||||
<p class="Normal" style="text-align:center;margin-bottom:0.0pt"><img width=305 height=206 id="Image1" src="{{png}}" alt="Image1"></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:228.8pt;padding:0.0pt 5.4pt 0.0pt 5.4pt">
|
||||
<p class="Normal" style="margin-bottom:0.0pt">30/01/2026</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:228.8pt;padding:0.0pt 5.4pt 0.0pt 5.4pt">
|
||||
<p class="Normal" style="margin-bottom:0.0pt">Note photo</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="Normal"></p>
|
||||
|
||||
<table class="TableauNormal Grilledutableau" cellspacing="0" cellpadding="0" width="auto" style="table-layout:fixed;width:378.005pt;border-collapse:collapse;">
|
||||
<tbody>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Nom - Prénom</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Tel. 1</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Tel. 2</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Rôle</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Étage</p></td>
|
||||
</tr>
|
||||
<tr style="height:auto">
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Dupont Jean</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">04 00 00 00 00</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">06 00 00 00 00</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">Occupant</p></td>
|
||||
<td valign="top" style="border-left:1pt solid #000000;border-top:1pt solid #000000;border-right:1pt solid #000000;border-bottom:1pt solid #000000;;width:75.6pt;padding:0.0pt 5.4pt 0.0pt 5.4pt"><p class="Normal" style="margin-bottom:0.0pt">4</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="Normal"><br /><span style="font-family:Arial;font-size:12pt;color:#000000;">Fin du rapport</span></p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using MimeKit;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests des images inline dans le corps HTML d'un email : résolution ChpImage
|
||||
/// (chemin absolu / Base64), marqueurs DOCX → <img> data URI (aperçu), puis
|
||||
/// conversion SMTP data URI → ressources MIME CID (multipart/related). Batik non
|
||||
/// régressé.
|
||||
/// </summary>
|
||||
public class EmailInlineImageTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static byte[] OctetsJpeg() => [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
|
||||
private static byte[] OctetsPng() => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D];
|
||||
private static byte[] OctetsBmp() => [0x42, 0x4D, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
private static string EcrireFichierTemporaire(byte[] octets, string extension)
|
||||
{
|
||||
string chemin = Path.Combine(Path.GetTempPath(), $"omika-img-{Guid.NewGuid():N}.{extension}");
|
||||
File.WriteAllBytes(chemin, octets);
|
||||
return chemin;
|
||||
}
|
||||
|
||||
private static RapportChampDto ChampImageSimple(string rubrique, string? valeur)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = rubrique,
|
||||
Type = "ChpImage",
|
||||
TypeMembre = "ChpImage",
|
||||
ListeDonnesBD = valeur
|
||||
};
|
||||
|
||||
private static RapportChampDto TableauPhotos(string rubrique, string prefixe, string json, params (string Rubrique, string Type, string MembreMob)[] colonnes)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = rubrique,
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre = colonnes.Select(c => new RapportChampSousElementDto
|
||||
{
|
||||
Rubrique = $"{prefixe}.{c.Rubrique}$",
|
||||
Type = c.Type,
|
||||
TypeMembre = c.Type,
|
||||
MembreMob = c.MembreMob
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static byte[] CreerDocx(params string[] paragraphes)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
foreach (string texte in paragraphes)
|
||||
{
|
||||
body.Append(new Paragraph(new Run(new Text(texte))));
|
||||
}
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreerDocxAvecTable(params string[][] lignes)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
var table = new Table();
|
||||
foreach (string[] ligne in lignes)
|
||||
{
|
||||
var row = new TableRow();
|
||||
foreach (string cellule in ligne)
|
||||
{
|
||||
row.Append(new TableCell(new Paragraph(new Run(new Text(cellule)))));
|
||||
}
|
||||
table.Append(row);
|
||||
}
|
||||
body.Append(table);
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string RendreCorps(IReadOnlyList<RapportChampDto> champs, byte[] docx, out IReadOnlyList<EmailInlineImage> images)
|
||||
{
|
||||
var liste = new List<EmailInlineImage>();
|
||||
byte[] rendu = EmailDocxRenderer.Rendre(docx, champs, liste);
|
||||
images = liste;
|
||||
return EmailImageInliner.Inliner(DocxHtmlConverter.Convertir(rendu), liste);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 1 : UNPHOTO JPEG chemin absolu → data URI + ressource inline
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UnPhoto_JpegCheminAbsolu_ProduitImageInline()
|
||||
{
|
||||
string chemin = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
|
||||
string html = RendreCorps(
|
||||
[ChampImageSimple("$UNPHOTO.IMAGE$", chemin)],
|
||||
CreerDocx("Photo: $UNPHOTO.IMAGE$"),
|
||||
out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Single(images);
|
||||
Assert.Equal("image/jpeg", images[0].MimeType);
|
||||
Assert.Contains("<img src=\"data:image/jpeg;base64,", html);
|
||||
Assert.DoesNotContain("$UNPHOTO.IMAGE$", html);
|
||||
|
||||
// Conversion SMTP : data URI → cid + ressource.
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(html);
|
||||
Assert.Contains("cid:", htmlSmtp);
|
||||
Assert.Single(ressources);
|
||||
Assert.Equal("image/jpeg", ressources[0].MimeType);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 2 : TABPHOTO 2 photos → 2 images distinctes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TabPhoto_DeuxPhotos_DeuxImagesDistinctes()
|
||||
{
|
||||
string chemin1 = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
string chemin2 = EcrireFichierTemporaire(OctetsPng(), "png");
|
||||
|
||||
string json = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { cheminComplet = chemin1, date = "30/01/2026", notes = "Note 1", nom = "a.jpg" },
|
||||
new { cheminComplet = chemin2, date = "30/01/2026", notes = "Note 2", nom = "b.png" }
|
||||
}, Json);
|
||||
|
||||
var champs = new[]
|
||||
{
|
||||
TableauPhotos(
|
||||
"$TABPHOTO$", "$TABPHOTO", json,
|
||||
("IMAGE", "ChpImage", "cheminComplet"),
|
||||
("DATEIMAGE", "ChpSimpleDate", "saDate"),
|
||||
("NOTEIMAGE", "ChpSimpleChaine", "saNotes"))
|
||||
};
|
||||
|
||||
string html = RendreCorps(
|
||||
champs,
|
||||
CreerDocxAvecTable(["$TABPHOTO.IMAGE$", "$TABPHOTO.DATEIMAGE$", "$TABPHOTO.NOTEIMAGE$"]),
|
||||
out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
// Deux images distinctes (JPEG + PNG).
|
||||
Assert.Equal(2, images.Count);
|
||||
Assert.Contains("data:image/jpeg;base64,", html);
|
||||
Assert.Contains("data:image/png;base64,", html);
|
||||
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(html);
|
||||
Assert.Equal(2, ressources.Count);
|
||||
Assert.Equal(2, ressources.Select(r => r.Cid).Distinct().Count());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 3 : photo + date + note + extra sur la même ligne
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Photo_Date_Note_Extra_MemeLigne()
|
||||
{
|
||||
string chemin1 = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
string chemin2 = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
|
||||
string json = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { cheminComplet = chemin1, date = "30/01/2026", notes = "Note 1", extra = "Extra A", nom = "a.jpg" },
|
||||
new { cheminComplet = chemin2, date = "31/01/2026", notes = "Note 2", extra = "Extra B", nom = "b.jpg" }
|
||||
}, Json);
|
||||
|
||||
var champs = new[]
|
||||
{
|
||||
TableauPhotos(
|
||||
"$TABPHOTO$", "$TABPHOTO", json,
|
||||
("IMAGE", "ChpImage", "cheminComplet"),
|
||||
("DATEIMAGE", "ChpSimpleDate", "saDate"),
|
||||
("NOTEIMAGE", "ChpSimpleChaine", "saNotes"),
|
||||
("EXTRA", "ChpSimpleChaine", "saExtra"))
|
||||
};
|
||||
|
||||
string html = RendreCorps(
|
||||
champs,
|
||||
CreerDocxAvecTable(["$TABPHOTO.IMAGE$", "$TABPHOTO.DATEIMAGE$", "$TABPHOTO.NOTEIMAGE$", "$TABPHOTO.EXTRA$"]),
|
||||
out _);
|
||||
|
||||
// Chaque ligne contient son image, sa date, sa note et son extra.
|
||||
Assert.Contains("data:image/jpeg;base64,", html);
|
||||
Assert.Contains("30/01/2026", html);
|
||||
Assert.Contains("31/01/2026", html);
|
||||
Assert.Contains("Note 1", html);
|
||||
Assert.Contains("Note 2", html);
|
||||
Assert.Contains("Extra A", html);
|
||||
Assert.Contains("Extra B", html);
|
||||
Assert.DoesNotContain("$TABPHOTO", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 4 : signature PNG Base64 → ressource inline
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Signature_PngBase64_RessourceInline()
|
||||
{
|
||||
string base64 = Convert.ToBase64String(OctetsPng());
|
||||
|
||||
string html = RendreCorps(
|
||||
[ChampImageSimple("$UNCR.IMAGESIGNATURE$", base64)],
|
||||
CreerDocx("Signature: $UNCR.IMAGESIGNATURE$"),
|
||||
out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Single(images);
|
||||
Assert.Equal("image/png", images[0].MimeType);
|
||||
Assert.Contains("<img src=\"data:image/png;base64,", html);
|
||||
Assert.DoesNotContain("$UNCR.IMAGESIGNATURE$", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 5 : image absente → email généré, aucun chemin visible
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImageAbsente_EmailGenere_SansChemin()
|
||||
{
|
||||
string cheminInexistant = Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.jpg");
|
||||
|
||||
string html = RendreCorps(
|
||||
[ChampImageSimple("$UNPHOTO.IMAGE$", cheminInexistant)],
|
||||
CreerDocx("Photo: $UNPHOTO.IMAGE$"),
|
||||
out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Empty(images);
|
||||
Assert.DoesNotContain("$UNPHOTO.IMAGE$", html);
|
||||
Assert.DoesNotContain("absent-", html);
|
||||
Assert.DoesNotContain("<img", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 7 : aucun chemin serveur (C:\, Documents_Demo) dans le HTML
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AucunCheminAbsolu_DansHtml()
|
||||
{
|
||||
string chemin = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
|
||||
string html = RendreCorps(
|
||||
[ChampImageSimple("$UNPHOTO.IMAGE$", chemin)],
|
||||
CreerDocx("Photo: $UNPHOTO.IMAGE$"),
|
||||
out _);
|
||||
|
||||
Assert.DoesNotContain(":\\", html);
|
||||
Assert.DoesNotContain(Path.GetFileName(chemin), html);
|
||||
Assert.Contains("data:image/jpeg;base64,", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 6 : aucun Base64 brut dans le HTML SMTP (CID)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Smtp_AucunBase64Brut_DansHtml()
|
||||
{
|
||||
string chemin = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
string htmlApercu = RendreCorps(
|
||||
[ChampImageSimple("$UNPHOTO.IMAGE$", chemin)],
|
||||
CreerDocx("Photo: $UNPHOTO.IMAGE$"),
|
||||
out _);
|
||||
|
||||
(string htmlSmtp, _) = EmailSmtpInline.Convertir(htmlApercu);
|
||||
|
||||
Assert.Contains("cid:", htmlSmtp);
|
||||
Assert.DoesNotContain("base64", htmlSmtp);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 9 : MIME resource ContentId correspond au src="cid:"
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Mime_ContentId_Correspond_SrcCid()
|
||||
{
|
||||
string chemin = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
string htmlApercu = RendreCorps(
|
||||
[ChampImageSimple("$UNPHOTO.IMAGE$", chemin)],
|
||||
CreerDocx("Photo: $UNPHOTO.IMAGE$"),
|
||||
out _);
|
||||
|
||||
var message = new EmailMessage
|
||||
{
|
||||
Sujet = "Sujet",
|
||||
CorpsHtml = htmlApercu,
|
||||
Destinataires = ["dest@test.fr"]
|
||||
};
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(message, "expediteur@test.fr");
|
||||
|
||||
string htmlSmtp = mime.HtmlBody;
|
||||
Assert.NotNull(htmlSmtp);
|
||||
Assert.Contains("cid:", htmlSmtp);
|
||||
|
||||
IReadOnlyList<MimePart> images = mime.BodyParts
|
||||
.OfType<MimePart>()
|
||||
.Where(p => p.ContentType.MediaType == "image")
|
||||
.ToList();
|
||||
Assert.Single(images);
|
||||
|
||||
string cid = images[0].ContentId;
|
||||
Assert.Contains($"src=\"cid:{cid}\"", htmlSmtp);
|
||||
Assert.DoesNotContain("base64", htmlSmtp);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 8 : placeholder ChpImage absent du résultat
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Placeholder_ChpImage_Absent_DuResultat()
|
||||
{
|
||||
string chemin = EcrireFichierTemporaire(OctetsJpeg(), "jpg");
|
||||
string html = RendreCorps(
|
||||
[ChampImageSimple("$TABPHOTO.IMAGE$", chemin)],
|
||||
CreerDocx("Photo: $TABPHOTO.IMAGE$"),
|
||||
out _);
|
||||
|
||||
Assert.DoesNotContain("$TABPHOTO.IMAGE$", html);
|
||||
Assert.Contains("<img", html);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 10 : Batik non-régression (syntaxe <...> toujours substituée)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Batik_SimplePlaceholder_ToujoursRemplace()
|
||||
{
|
||||
var champs = new[]
|
||||
{
|
||||
new RapportChampDto
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = "<CLIENT.NOM>",
|
||||
Type = "ChpSimpleChaine",
|
||||
TypeMembre = "ChpSimpleChaine",
|
||||
ListeDonnesBD = "Acme"
|
||||
}
|
||||
};
|
||||
|
||||
string html = RendreCorps(champs, CreerDocx("Client: <CLIENT.NOM>"), out IReadOnlyList<EmailInlineImage> images);
|
||||
|
||||
Assert.Empty(images);
|
||||
Assert.Contains("Client: Acme", html);
|
||||
Assert.DoesNotContain("<CLIENT.NOM>", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using MimeKit;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests E2E de la STRUCTURE MIME des images inline Email : les images doivent
|
||||
/// être inline dans le corps (multipart/related + Content-Disposition:inline +
|
||||
/// cid exact), JAMAIS en pièces jointes classiques (multipart/mixed/attachment).
|
||||
/// </summary>
|
||||
public class EmailMimeStructureTests
|
||||
{
|
||||
private static byte[] Jpeg() => [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
|
||||
private static byte[] Jpeg2() => [0xFF, 0xD8, 0xFF, 0xE1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
|
||||
private static byte[] Png() => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
|
||||
|
||||
private static string DataUri(byte[] bytes, string mime)
|
||||
=> $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
|
||||
|
||||
private static EmailMessage MessageAvecCorpsHtml(string html, string? corpsTexte = null)
|
||||
=> new()
|
||||
{
|
||||
Sujet = "Test",
|
||||
CorpsHtml = html,
|
||||
CorpsTexte = corpsTexte,
|
||||
Destinataires = ["dest@test.fr"]
|
||||
};
|
||||
|
||||
private static IEnumerable<MimePart> Parcourir(MimeEntity entity)
|
||||
{
|
||||
if (entity is MimePart part)
|
||||
{
|
||||
yield return part;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (entity is Multipart multipart)
|
||||
{
|
||||
foreach (MimeEntity enfant in multipart)
|
||||
{
|
||||
foreach (MimePart descendant in Parcourir(enfant))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MimePart> Images(MimeMessage mime)
|
||||
=> Parcourir(mime.Body).Where(p => p.ContentType.MediaType == "image").ToList();
|
||||
|
||||
private static List<MimePart> Attachements(MimeMessage mime)
|
||||
=> Parcourir(mime.Body)
|
||||
.Where(p => p.ContentDisposition?.Disposition == ContentDisposition.Attachment)
|
||||
.ToList();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 1 : 2 photos → LinkedResources=2, Attachments=0
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeuxPhotos_DeuxRessourcesInline_ZeroAttachements()
|
||||
{
|
||||
string html =
|
||||
$"<table><tr><td><img src=\"{DataUri(Jpeg(), "image/jpeg")}\"></td><td>30/01/2026</td></tr>" +
|
||||
$"<tr><td><img src=\"{DataUri(Jpeg2(), "image/jpeg")}\"></td><td>31/01/2026</td></tr></table>";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
Assert.Equal(2, Images(mime).Count);
|
||||
Assert.Empty(Attachements(mime));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 2 : toutes les ressources ont Content-Disposition:inline
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ToutesRessources_ContentDispositionInline()
|
||||
{
|
||||
string html =
|
||||
$"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Png(), "image/png")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
List<MimePart> images = Images(mime);
|
||||
Assert.NotEmpty(images);
|
||||
Assert.All(images, img => Assert.Equal(ContentDisposition.Inline, img.ContentDisposition?.Disposition));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 3 : HTML cid ↔ ContentId exact
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void HtmlCid_CorrespondExactementContentId()
|
||||
{
|
||||
string html = $"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
string htmlFinal = mime.HtmlBody;
|
||||
Assert.NotNull(htmlFinal);
|
||||
|
||||
List<MimePart> images = Images(mime);
|
||||
Assert.Single(images);
|
||||
|
||||
string cid = images[0].ContentId;
|
||||
Assert.False(string.IsNullOrEmpty(cid));
|
||||
Assert.Contains($"src=\"cid:{cid}\"", htmlFinal);
|
||||
// Le HTML ne contient pas les chevrons <…> du header MIME.
|
||||
Assert.DoesNotContain($"cid:<{cid}>", htmlFinal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 4 : multipart/related contient les images
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Images_DansMultipartRelated()
|
||||
{
|
||||
string html = $"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
// Sans texte alternatif : corps racine = multipart/related (MultipartRelated).
|
||||
Assert.IsAssignableFrom<Multipart>(mime.Body);
|
||||
Assert.Equal("related", mime.Body.ContentType.MediaSubtype);
|
||||
Assert.Single(Images(mime));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 5 : aucune image sous multipart/mixed (pièce jointe)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AucuneImage_SousMultipartMixed()
|
||||
{
|
||||
string html =
|
||||
$"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Png(), "image/png")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
Assert.Empty(Attachements(mime));
|
||||
// Aucune image ne doit se trouver sous un conteneur multipart/mixed.
|
||||
foreach (MimePart image in Images(mime))
|
||||
{
|
||||
Assert.False(ParentMixed(mime.Body, image), "Une image inline est tombée sous multipart/mixed.");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 6 : 2 photos + signature → 3 ressources distinctes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DeuxPhotos_UneSignature_TroisRessourcesDistinctes()
|
||||
{
|
||||
string html =
|
||||
$"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Jpeg2(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Png(), "image/png")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
List<MimePart> images = Images(mime);
|
||||
Assert.Equal(3, images.Count);
|
||||
Assert.Equal(3, images.Select(i => i.ContentId).Distinct().Count());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 7 : plusieurs data URI → toutes converties (aucune ignorée)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PlusieursDataUri_ToutesConverties()
|
||||
{
|
||||
string html =
|
||||
$"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Jpeg2(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Png(), "image/png")}\">";
|
||||
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(html);
|
||||
|
||||
Assert.Equal(3, ressources.Count);
|
||||
Assert.DoesNotContain("data:", htmlSmtp);
|
||||
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(htmlSmtp, "cid:").Count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 8 : attributs img différents (guillemets simples / ordre) → robuste
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AttributsImgDifferents_ConversionRobuste()
|
||||
{
|
||||
// Guillemets simples + attribut style AVANT src.
|
||||
string a = $"<img style=\"width:100%\" src='{DataUri(Jpeg(), "image/jpeg")}'>";
|
||||
// Guillemets doubles + src en premier.
|
||||
string b = $"<img src=\"{DataUri(Png(), "image/png")}\" alt=\"sig\">";
|
||||
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(a + b);
|
||||
|
||||
Assert.Equal(2, ressources.Count);
|
||||
Assert.DoesNotContain("data:", htmlSmtp);
|
||||
Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(htmlSmtp, "cid:").Count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 9 : .eml sauvegardé → relecture MimeKit → images inline retrouvées
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Eml_SauvegardePuisRelu_ImagesInlineRetrouvees()
|
||||
{
|
||||
string html =
|
||||
$"<img src=\"{DataUri(Jpeg(), "image/jpeg")}\">" +
|
||||
$"<img src=\"{DataUri(Png(), "image/png")}\">";
|
||||
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr");
|
||||
|
||||
string repertoire = Path.Combine(Path.GetTempPath(), "omika-eml-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(repertoire);
|
||||
string eml = Path.Combine(repertoire, "email-inline-diagnostic.eml");
|
||||
mime.WriteTo(eml);
|
||||
|
||||
MimeMessage relu = MimeMessage.Load(eml);
|
||||
|
||||
Assert.Equal(2, Images(relu).Count);
|
||||
Assert.All(Images(relu), img => Assert.Equal(ContentDisposition.Inline, img.ContentDisposition?.Disposition));
|
||||
Assert.Empty(Attachements(relu));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TEST 10 : Batik non-régression (pas d'image → aucun changement)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Batik_SansImage_AucuneRessourceInline()
|
||||
{
|
||||
// Batik : corps HTML simple (aucune image) → aucune ressource inline.
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(
|
||||
MessageAvecCorpsHtml("<p>Bonjour Batik</p>", corpsTexte: "Bonjour Batik"),
|
||||
"exp@test.fr");
|
||||
|
||||
Assert.Empty(Images(mime));
|
||||
Assert.Empty(Attachements(mime));
|
||||
Assert.Contains("Bonjour Batik", mime.HtmlBody);
|
||||
}
|
||||
|
||||
private static bool ParentMixed(MimeEntity parent, MimePart cible)
|
||||
{
|
||||
if (parent is not Multipart multipart)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (multipart.ContentType.MediaSubtype == "mixed" && multipart.Contains(cible))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return multipart.OfType<MimeEntity>().Any(enfant => ParentMixed(enfant, cible));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Security.Cryptography;
|
||||
using MimeKit;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic roundtrip : suit la même image (par SHA256) à travers
|
||||
/// APERÇU (data URI) → REQUÊTE mobile (écho) → SMTP (ressource CID), pour
|
||||
/// identifier précisément l'étape où une image disparaîtrait. Aucun contenu
|
||||
/// n'est journalisé — uniquement des empreintes.
|
||||
/// </summary>
|
||||
public class EmailRoundtripDiagnosticTests
|
||||
{
|
||||
private static byte[] JpegA() => [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
|
||||
private static byte[] JpegB() => [0xFF, 0xD8, 0xFF, 0xE1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
|
||||
|
||||
private static string DataUri(byte[] bytes) => $"data:image/jpeg;base64,{Convert.ToBase64String(bytes)}";
|
||||
|
||||
private static byte[] Contenu(MimePart part)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
part.Content.DecodeTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static string Sha256(byte[] data)
|
||||
=> Convert.ToHexString(SHA256.HashData(data));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// L'analyseur produit des empreintes déterministes (sans contenu)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Analyser_CompteEtEmpreintes_Deterministes()
|
||||
{
|
||||
string html = $"<img src=\"{DataUri(JpegA())}\"><img src=\"{DataUri(JpegB())}\">";
|
||||
|
||||
(int imgCount, int dataUriCount, IReadOnlyList<EmailInlineDiagnostic.ImageTrace> images) =
|
||||
EmailInlineDiagnostic.Analyser(html);
|
||||
|
||||
Assert.Equal(2, imgCount);
|
||||
Assert.Equal(2, dataUriCount);
|
||||
Assert.Equal(2, images.Count);
|
||||
Assert.All(images, i => Assert.Equal("image/jpeg", i.Mime));
|
||||
Assert.Equal(Sha256(JpegA()), images[0].Sha256);
|
||||
Assert.Equal(Sha256(JpegB()), images[1].Sha256);
|
||||
Assert.DoesNotContain("base64", images[0].Sha256);
|
||||
Assert.DoesNotContain(Convert.ToBase64String(JpegA()), images[0].Sha256);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Roundtrip : APERÇU → REQUÊTE → SMTP (mêmes empreintes)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_HashApercu_Request_Smtp_Identiques()
|
||||
{
|
||||
string preview = $"<table><tr><td><img src=\"{DataUri(JpegA())}\"></td><td>30/01/2026</td></tr>" +
|
||||
$"<tr><td><img src=\"{DataUri(JpegB())}\"></td><td>31/01/2026</td></tr></table>";
|
||||
|
||||
// Étape 1 : APERÇU (serveur → mobile).
|
||||
(_, _, IReadOnlyList<EmailInlineDiagnostic.ImageTrace> apercu) = EmailInlineDiagnostic.Analyser(preview);
|
||||
|
||||
// Étape 2 : REQUÊTE (le mobile renvoie le HTML inchangé).
|
||||
string request = preview;
|
||||
(_, _, IReadOnlyList<EmailInlineDiagnostic.ImageTrace> requete) = EmailInlineDiagnostic.Analyser(request);
|
||||
|
||||
// Étape 3 : SMTP (data URI → ressource CID).
|
||||
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(request);
|
||||
Assert.Equal(2, ressources.Count);
|
||||
Assert.DoesNotContain("data:", htmlSmtp);
|
||||
|
||||
// Étape 4 : MimeMessage réel (LinkedResources).
|
||||
MimeMessage mime = MailKitEmailSender.ConstruireMime(
|
||||
new EmailMessage { Sujet = "S", CorpsHtml = request, Destinataires = ["d@x.fr"] },
|
||||
"exp@x.fr");
|
||||
List<MimePart> imagesMime = mime.BodyParts.OfType<MimePart>()
|
||||
.Where(p => p.ContentType.MediaType == "image").ToList();
|
||||
Assert.Equal(2, imagesMime.Count);
|
||||
|
||||
// Correspondance par SHA256, photo par photo.
|
||||
Assert.Equal(apercu[0].Sha256, requete[0].Sha256);
|
||||
Assert.Equal(apercu[1].Sha256, requete[1].Sha256);
|
||||
Assert.Equal(apercu[0].Sha256, Sha256(ressources[0].Data));
|
||||
Assert.Equal(apercu[1].Sha256, Sha256(ressources[1].Data));
|
||||
Assert.Equal(apercu[0].Sha256, Sha256(Contenu(imagesMime[0])));
|
||||
Assert.Equal(apercu[1].Sha256, Sha256(Contenu(imagesMime[1])));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Perte simulée : une image absente de la requête → détectée par hash
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_ImageManquante_DetecteeParHash()
|
||||
{
|
||||
string preview = $"<img src=\"{DataUri(JpegA())}\"><img src=\"{DataUri(JpegB())}\">";
|
||||
// Le mobile ne renvoie qu'UNE image (ex. troncature) → la seconde disparaît.
|
||||
string request = $"<img src=\"{DataUri(JpegA())}\">";
|
||||
|
||||
(_, _, IReadOnlyList<EmailInlineDiagnostic.ImageTrace> apercu) = EmailInlineDiagnostic.Analyser(preview);
|
||||
(_, _, IReadOnlyList<EmailInlineDiagnostic.ImageTrace> requete) = EmailInlineDiagnostic.Analyser(request);
|
||||
|
||||
Assert.Equal(2, apercu.Count);
|
||||
Assert.Single(requete);
|
||||
Assert.Equal(apercu[0].Sha256, requete[0].Sha256);
|
||||
// La photo B est perdue au roundtrip : détectable par absence d'empreinte.
|
||||
Assert.DoesNotContain(requete, i => i.Sha256 == apercu[1].Sha256);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires de <see cref="MailKitEmailSender"/> : les validations qui
|
||||
/// échouent AVANT toute connexion SMTP (configuration incomplète, destinataire
|
||||
/// manquant) sont déterministes et testables sans serveur de messagerie.
|
||||
///
|
||||
/// Depuis la refonte de la résolution SMTP, le message utilisateur NOMME le champ
|
||||
/// manquant (« Configuration SMTP « compte » incomplète : le serveur de messagerie
|
||||
/// n'est pas renseigné. ») : le message générique unique ne permettait pas de
|
||||
/// savoir quoi corriger, dans la fiche de la société ou dans les préférences.
|
||||
/// </summary>
|
||||
public class MailKitEmailSenderTests
|
||||
{
|
||||
private static SmtpConfiguration Config(
|
||||
string? serveur = "smtp.test",
|
||||
string? email = "expediteur@test.fr",
|
||||
string? login = "expediteur@test.fr")
|
||||
=> new()
|
||||
{
|
||||
Id = "cfg",
|
||||
Email = email,
|
||||
Login = login,
|
||||
Serveur = serveur,
|
||||
Port = 587,
|
||||
Cryptage = 2
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_SansServeur_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
Config(serveur: null)));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP", ex.Message);
|
||||
Assert.Contains("serveur", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_SansDestinataire_LeveDestinataireInvalide()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = [] },
|
||||
Config()));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.DestinataireInvalide, ex.Type);
|
||||
Assert.Equal("Veuillez renseigner au moins un destinataire.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_SansExpediteur_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
Config(email: null, login: null)));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_SansMotDePasse_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
Config()));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP", ex.Message);
|
||||
Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ===== Validation de configuration avant connexion (aucun appel SMTP) =====
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_PortInvalide_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
var config = new SmtpConfiguration
|
||||
{
|
||||
Id = "cfg",
|
||||
Email = "expediteur@test.fr",
|
||||
Login = "expediteur@test.fr",
|
||||
Serveur = "smtp.test",
|
||||
Port = 0,
|
||||
Cryptage = 2,
|
||||
MotDePasse = "secret"
|
||||
};
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
config));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP", ex.Message);
|
||||
Assert.Contains("port", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_CryptageInvalide_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
var config = new SmtpConfiguration
|
||||
{
|
||||
Id = "cfg",
|
||||
Email = "expediteur@test.fr",
|
||||
Login = "expediteur@test.fr",
|
||||
Serveur = "smtp.test",
|
||||
Port = 587,
|
||||
Cryptage = 9,
|
||||
MotDePasse = "secret"
|
||||
};
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
config));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP", ex.Message);
|
||||
Assert.Contains("cryptage", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvoyerAsync_LoginVideAvecMotDePasse_LeveConfigurationIncomplete()
|
||||
{
|
||||
var sender = new MailKitEmailSender();
|
||||
var config = new SmtpConfiguration
|
||||
{
|
||||
Id = "cfg",
|
||||
Email = "expediteur@test.fr",
|
||||
Login = null,
|
||||
Serveur = "smtp.test",
|
||||
Port = 587,
|
||||
Cryptage = 2,
|
||||
MotDePasse = "secret"
|
||||
};
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(() =>
|
||||
sender.EnvoyerAsync(
|
||||
new EmailMessage { Sujet = "Sujet", Destinataires = new[] { "dest@test.fr" } },
|
||||
config));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP", ex.Message);
|
||||
Assert.Contains("login", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du normalisateur de MembreMob : préfixes hongrois (<c>sa</c>), préfixes
|
||||
/// tables (<c>DC_</c>, <c>CR_</c>) et mappages explicites vers le camelCase des
|
||||
/// DTO JSON.
|
||||
/// </summary>
|
||||
public class MembreMobNormalizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("saNom", "nom")]
|
||||
[InlineData("saRole", "role")]
|
||||
[InlineData("saTFix", "telFixe")]
|
||||
[InlineData("saTMob", "telMobile")]
|
||||
[InlineData("saDateDebut", "dateDebut")]
|
||||
[InlineData("saNotes", "notes")]
|
||||
[InlineData("saExtra", "extra")]
|
||||
[InlineData("DC_Nom", "nom")]
|
||||
[InlineData("DC_DateFichier", "date")]
|
||||
public void Normaliser_MembresCommuns(string membreMob, string attendu)
|
||||
=> Assert.Equal(attendu, MembreMobNormalizer.Normaliser(membreMob));
|
||||
|
||||
[Theory]
|
||||
[InlineData("CR_NomSignataire", "signataire")]
|
||||
[InlineData("CR_NomSignataireTech", "collaborateur")]
|
||||
[InlineData("CR_DateHeureDebut", "debut")]
|
||||
[InlineData("CR_DateHeureFin", "fin")]
|
||||
public void Normaliser_MembresCompteRendu(string membreMob, string attendu)
|
||||
=> Assert.Equal(attendu, MembreMobNormalizer.Normaliser(membreMob));
|
||||
|
||||
[Fact]
|
||||
public void Normaliser_Vide_OuBlanc_RetourneNull()
|
||||
{
|
||||
Assert.Null(MembreMobNormalizer.Normaliser(null));
|
||||
Assert.Null(MembreMobNormalizer.Normaliser(""));
|
||||
Assert.Null(MembreMobNormalizer.Normaliser(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Contracts.Interventions;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Core.Domain.Entities.Documents;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Services;
|
||||
using Omika.Data.Repositories;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prouve que trois rapports de même nom produisent trois fichiers distincts via
|
||||
/// le mécanisme standard des documents (<see cref="DocumentPathResolver.WriteUniqueFile"/>),
|
||||
/// SANS GUID dans le nom physique, avec nom en base == nom physique.
|
||||
/// </summary>
|
||||
public class RapportCreationServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public RapportCreationServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "omika-rapport-collision", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_dir))
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeModeleRapportRepository : IModeleRapportRepository
|
||||
{
|
||||
public Task<IReadOnlyList<ModeleRapport>> GetAllAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<ModeleRapport>>([]);
|
||||
public Task<ModeleRapport?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
=> Task.FromResult<ModeleRapport?>(null);
|
||||
public Task<byte[]?> GetModeleDocxAsync(long modeleId, CancellationToken ct = default)
|
||||
=> Task.FromResult<byte[]?>([1, 2, 3]);
|
||||
}
|
||||
|
||||
private sealed class FakePdfService : IRapportPdfService
|
||||
{
|
||||
private int _calls;
|
||||
public Task<byte[]> GenererPdfAsync(byte[] modeleDocx, IReadOnlyList<RapportChampDto> champsRemplis, CancellationToken ct = default)
|
||||
{
|
||||
// Contenu distinct à chaque génération pour prouver l'absence d'écrasement.
|
||||
_calls++;
|
||||
return Task.FromResult(new byte[] { (byte)_calls, 9, 9, 9 });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRapportChampService : IRapportChampService
|
||||
{
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsAsync(
|
||||
GetRapportChampsRequest request, bool toutesRub = false, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<RapportChampDto>>([]);
|
||||
|
||||
public Task<IReadOnlyList<RapportChampDto>> GetChampsMailAsync(
|
||||
GetRapportChampsRequest request, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<RapportChampDto>>([]);
|
||||
}
|
||||
|
||||
private sealed class FakeInterventionService : IInterventionService
|
||||
{
|
||||
public Task<InterventionDetailsDto?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
=> Task.FromResult<InterventionDetailsDto?>(new InterventionDetailsDto
|
||||
{
|
||||
Id = id,
|
||||
SourceReference = LegacyEntityReference.FromLegacyId(7)
|
||||
});
|
||||
|
||||
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(SearchInterventionsRequest request, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(SearchInterventionsRequest request, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<InterventionDetailsDto> CreateAsync(CreateInterventionRequest request, CancellationToken ct = default, AuditActor? auteur = null)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<InterventionDetailsDto?> UpdateAsync(string id, UpdateInterventionRequest request, CancellationToken ct = default, AuditActor? auteur = null)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(string interventionId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<NoteInterneDto> AddNoteAsync(string interventionId, string contenu, AuditActor? auteur, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(string interventionId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<EmailInterventionDto> AddEmailAsync(string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class FakeDocumentRepository : IDocumentRepository
|
||||
{
|
||||
private readonly string _directory;
|
||||
|
||||
public FakeDocumentRepository(string directory) => _directory = directory;
|
||||
|
||||
public List<Document> Documents { get; } = [];
|
||||
|
||||
public Task<DocumentWriteResult?> WriteDocumentFileAsync(
|
||||
long interventionId, long categorieId, string fileName, byte[] content, CancellationToken ct = default)
|
||||
{
|
||||
// Réutilise EXACTEMENT le mécanisme standard de collision des documents.
|
||||
string? finalName = DocumentPathResolver.WriteUniqueFile(_directory, fileName, content);
|
||||
return Task.FromResult<DocumentWriteResult?>(
|
||||
finalName is null ? null : new DocumentWriteResult(_directory, finalName));
|
||||
}
|
||||
|
||||
public Task<Document> CreateAsync(Document document, CancellationToken ct = default)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<long> ResolveCreationUserAsync(AuditActor? auteur, CancellationToken ct = default)
|
||||
=> Task.FromResult(1L);
|
||||
public Task<long> ResolveCategoryIdAsync(string categorieCle, CancellationToken ct = default)
|
||||
=> Task.FromResult(2L);
|
||||
|
||||
public Task<PagedResult<Document>> SearchAsync(PagedRequest request, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<Document>> GetByInterventionAsync(PhysicalEntityIdentity interventionIdentity, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<Document>> GetByInterventionsAsync(IReadOnlyList<long> numericInterventionIds, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<Document?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<Document?> GetByPhysicalIdentityAsync(PhysicalEntityIdentity identity, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<string?> ResolveDocumentFullPathAsync(Document document, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(long documentId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public Task<NoteInterneDto> AddNoteAsync(long documentId, string contenu, long creationUser, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TroisRapportsMemeNom_TroisFichiersDistincts_SansGuid()
|
||||
{
|
||||
var documentRepository = new FakeDocumentRepository(_dir);
|
||||
var service = new RapportCreationService(
|
||||
new FakeModeleRapportRepository(),
|
||||
new FakePdfService(),
|
||||
documentRepository,
|
||||
new FakeInterventionService(),
|
||||
new FakeRapportChampService());
|
||||
|
||||
DocumentSummaryDto? premier = await service.CreerAsync(new CreateRapportRequest
|
||||
{
|
||||
ModeleId = "42",
|
||||
InterventionId = Guid.NewGuid().ToString(),
|
||||
Nom = "Rapport intervention",
|
||||
RapportGuid = Guid.NewGuid()
|
||||
}, auteur: null);
|
||||
|
||||
DocumentSummaryDto? second = await service.CreerAsync(new CreateRapportRequest
|
||||
{
|
||||
ModeleId = "42",
|
||||
InterventionId = Guid.NewGuid().ToString(),
|
||||
Nom = "Rapport intervention",
|
||||
RapportGuid = Guid.NewGuid()
|
||||
}, auteur: null);
|
||||
|
||||
DocumentSummaryDto? troisieme = await service.CreerAsync(new CreateRapportRequest
|
||||
{
|
||||
ModeleId = "42",
|
||||
InterventionId = Guid.NewGuid().ToString(),
|
||||
Nom = "Rapport intervention",
|
||||
RapportGuid = Guid.NewGuid()
|
||||
}, auteur: null);
|
||||
|
||||
Assert.NotNull(premier);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotNull(troisieme);
|
||||
|
||||
// Noms physiques = noms en base, sans GUID.
|
||||
Assert.Equal("Rapport intervention.pdf", premier!.Nom);
|
||||
Assert.Equal("Rapport intervention_1.pdf", second!.Nom);
|
||||
Assert.Equal("Rapport intervention_2.pdf", troisieme!.Nom);
|
||||
|
||||
// Les trois fichiers existent simultanément.
|
||||
string[] files = Directory.GetFiles(_dir);
|
||||
Assert.Equal(3, files.Length);
|
||||
Assert.Contains(Path.Combine(_dir, "Rapport intervention.pdf"), files);
|
||||
Assert.Contains(Path.Combine(_dir, "Rapport intervention_1.pdf"), files);
|
||||
Assert.Contains(Path.Combine(_dir, "Rapport intervention_2.pdf"), files);
|
||||
|
||||
// Chaque fichier a son propre contenu (aucun écrasement).
|
||||
byte[] content1 = await File.ReadAllBytesAsync(Path.Combine(_dir, "Rapport intervention.pdf"));
|
||||
byte[] content2 = await File.ReadAllBytesAsync(Path.Combine(_dir, "Rapport intervention_1.pdf"));
|
||||
byte[] content3 = await File.ReadAllBytesAsync(Path.Combine(_dir, "Rapport intervention_2.pdf"));
|
||||
Assert.Equal((byte)1, content1[0]);
|
||||
Assert.Equal((byte)2, content2[0]);
|
||||
Assert.Equal((byte)3, content3[0]);
|
||||
|
||||
// DB : nom en base == nom physique (3 enregistrements distincts).
|
||||
Assert.Equal(3, documentRepository.Documents.Count);
|
||||
Assert.Equal("Rapport intervention.pdf", documentRepository.Documents[0].Nom);
|
||||
Assert.Equal("Rapport intervention_1.pdf", documentRepository.Documents[1].Nom);
|
||||
Assert.Equal("Rapport intervention_2.pdf", documentRepository.Documents[2].Nom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.IO.Packaging;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Services;
|
||||
using WP = DocumentFormat.OpenXml.Drawing.Wordprocessing;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de l'insertion d'images ChpImage (Base64 / fichier) dans le DOCX par
|
||||
/// <see cref="RapportPdfService"/> : Drawing OpenXML + ImagePart, suppression du
|
||||
/// placeholder, non-régression des champs texte, conservation du ratio.
|
||||
/// </summary>
|
||||
public class RapportPdfServiceImageTests
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Construit des octets « PNG-like » (magic bytes + dimensions) pour les tests DOCX.</summary>
|
||||
private static byte[] FakePng(int largeur, int hauteur)
|
||||
{
|
||||
byte[] png = new byte[24];
|
||||
png[0] = 0x89; png[1] = 0x50; png[2] = 0x4E; png[3] = 0x47;
|
||||
png[4] = 0x0D; png[5] = 0x0A; png[6] = 0x1A; png[7] = 0x0A;
|
||||
png[12] = 0x49; png[13] = 0x48; png[14] = 0x44; png[15] = 0x52; // "IHDR"
|
||||
png[16] = (byte)(largeur >> 24); png[17] = (byte)(largeur >> 16);
|
||||
png[18] = (byte)(largeur >> 8); png[19] = (byte)largeur;
|
||||
png[20] = (byte)(hauteur >> 24); png[21] = (byte)(hauteur >> 16);
|
||||
png[22] = (byte)(hauteur >> 8); png[23] = (byte)hauteur;
|
||||
return png;
|
||||
}
|
||||
|
||||
private static byte[] CreerDocx(params string[] paragraphes)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
foreach (string texte in paragraphes)
|
||||
{
|
||||
body.Append(new Paragraph(new Run(new Text(texte))));
|
||||
}
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreerDocxAvecTableau(params string[] cellules)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
|
||||
{
|
||||
MainDocumentPart main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document();
|
||||
Body body = new Body();
|
||||
var table = new Table();
|
||||
var row = new TableRow();
|
||||
foreach (string cellule in cellules)
|
||||
{
|
||||
row.Append(new TableCell(new Paragraph(new Run(new Text(cellule)))));
|
||||
}
|
||||
table.Append(row);
|
||||
body.Append(table);
|
||||
main.Document.Append(body);
|
||||
main.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static RapportChampDto ChampImage(string rubrique, string? valeur, string? membreMob = null)
|
||||
=> new()
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = rubrique,
|
||||
Type = "ChpImage",
|
||||
TypeMembre = "ChpImage",
|
||||
MembreMob = membreMob,
|
||||
ListeDonnesBD = valeur
|
||||
};
|
||||
|
||||
private static (int ImageParts, int Drawings, string Texte, IReadOnlyList<string> Extents) Inspecter(byte[] docx)
|
||||
{
|
||||
int imageParts;
|
||||
using (var pkgStream = new MemoryStream(docx))
|
||||
using (Package package = Package.Open(pkgStream, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
imageParts = package.GetParts()
|
||||
.Count(p => p.Uri.OriginalString.StartsWith("/word/media/", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream(docx);
|
||||
using WordprocessingDocument doc = WordprocessingDocument.Open(stream, false);
|
||||
MainDocumentPart main = doc.MainDocumentPart!;
|
||||
int drawings = main.Document.Body!.Descendants<Drawing>().Count();
|
||||
string texte = string.Concat(main.Document.Body.Descendants<Text>().Select(t => t.Text));
|
||||
var extents = main.Document.Body.Descendants<WP.Extent>()
|
||||
.Select(e => $"{e.Cx!.Value}x{e.Cy!.Value}")
|
||||
.ToList();
|
||||
return (imageParts, drawings, texte, extents);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ChpImageSimple_Base64Png_InsereDrawingEtImagePart()
|
||||
{
|
||||
string base64 = Convert.ToBase64String(FakePng(256, 100));
|
||||
byte[] docx = CreerDocx("Signature: $UNCR.IMAGESIGNATURE$");
|
||||
var champs = new[] { ChampImage("$UNCR.IMAGESIGNATURE$", base64, "CR_Signature") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(1, imageParts);
|
||||
Assert.Equal(1, drawings);
|
||||
Assert.Contains("Signature: ", texte);
|
||||
Assert.DoesNotContain("$UNCR.IMAGESIGNATURE$", texte);
|
||||
Assert.DoesNotContain(base64, texte); // aucun Base64 visible
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageSimple_DeuxSignatures_DeuxImagesDistinctes()
|
||||
{
|
||||
string signature1 = Convert.ToBase64String(FakePng(256, 100));
|
||||
string signature2 = Convert.ToBase64String(FakePng(512, 200));
|
||||
byte[] docx = CreerDocx("S1: $UNCR.IMAGESIGNATURE$", "S2: $UNCR.IMAGESIGNATURETECH$");
|
||||
var champs = new[]
|
||||
{
|
||||
ChampImage("$UNCR.IMAGESIGNATURE$", signature1, "CR_Signature"),
|
||||
ChampImage("$UNCR.IMAGESIGNATURETECH$", signature2, "CR_SignatureTech")
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(2, imageParts);
|
||||
Assert.Equal(2, drawings);
|
||||
Assert.DoesNotContain(signature1, texte);
|
||||
Assert.DoesNotContain(signature2, texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageSimple_SignatureVide_PlaceholderSupprime()
|
||||
{
|
||||
byte[] docx = CreerDocx("Signature: $UNCR.IMAGESIGNATURE$");
|
||||
var champs = new[] { ChampImage("$UNCR.IMAGESIGNATURE$", null, "CR_Signature") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(0, imageParts);
|
||||
Assert.Equal(0, drawings);
|
||||
Assert.DoesNotContain("$UNCR.IMAGESIGNATURE$", texte);
|
||||
Assert.DoesNotContain("null", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageTableau_DeuxSignatures_DeuxImagesDansDeuxLignes()
|
||||
{
|
||||
string signature1 = Convert.ToBase64String(FakePng(256, 100));
|
||||
string signature2 = Convert.ToBase64String(FakePng(512, 200));
|
||||
byte[] docx = CreerDocxAvecTableau("$TABCR.IMAGESIGNATURE$");
|
||||
string json = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { signature = signature1 },
|
||||
new { signature = signature2 }
|
||||
});
|
||||
|
||||
var tableau = new RapportChampDto
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = "$TABCOMPTERENDU$",
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre = new[]
|
||||
{
|
||||
new RapportChampSousElementDto { Rubrique = "$TABCR.IMAGESIGNATURE$", MembreMob = "CR_Signature", Type = "ChpImage" }
|
||||
}
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, new[] { tableau });
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(2, imageParts);
|
||||
Assert.Equal(2, drawings);
|
||||
Assert.DoesNotContain("$TABCR.IMAGESIGNATURE$", texte);
|
||||
Assert.DoesNotContain(signature1, texte);
|
||||
Assert.DoesNotContain(signature2, texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageSimple_Unphoto_InsereImage()
|
||||
{
|
||||
string base64 = Convert.ToBase64String(FakePng(320, 240));
|
||||
byte[] docx = CreerDocx("Photo: $UNPHOTO.IMAGE$");
|
||||
var champs = new[] { ChampImage("$UNPHOTO.IMAGE$", base64, "saNom") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(1, imageParts);
|
||||
Assert.Equal(1, drawings);
|
||||
Assert.DoesNotContain("$UNPHOTO.IMAGE$", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageTableau_DeuxPhotos_DeuxImages()
|
||||
{
|
||||
string photo1 = Convert.ToBase64String(FakePng(320, 240));
|
||||
string photo2 = Convert.ToBase64String(FakePng(640, 480));
|
||||
byte[] docx = CreerDocxAvecTableau("$TABPHOTO.IMAGE$");
|
||||
string json = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { nom = photo1 },
|
||||
new { nom = photo2 }
|
||||
});
|
||||
|
||||
var tableau = new RapportChampDto
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = "$TABPHOTO$",
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre = new[]
|
||||
{
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.IMAGE$", MembreMob = "DC_Nom", Type = "ChpImage" }
|
||||
}
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, new[] { tableau });
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(2, imageParts);
|
||||
Assert.Equal(2, drawings);
|
||||
Assert.DoesNotContain("$TABPHOTO.IMAGE$", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImageTableau_ImageDateNote_SurBonneLigne()
|
||||
{
|
||||
string photo1 = Convert.ToBase64String(FakePng(320, 240));
|
||||
string photo2 = Convert.ToBase64String(FakePng(640, 480));
|
||||
byte[] docx = CreerDocxAvecTableau("$TABPHOTO.IMAGE$", "$TABPHOTO.DATEIMAGE$", "$TABPHOTO.NOTEIMAGE$");
|
||||
string json = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { nom = photo1, date = "2024-01-01", notes = "Note A" },
|
||||
new { nom = photo2, date = "2024-01-02", notes = "Note B" }
|
||||
});
|
||||
|
||||
var tableau = new RapportChampDto
|
||||
{
|
||||
Id = "1",
|
||||
Rubrique = "$TABPHOTO$",
|
||||
Type = "Tableau",
|
||||
ListeDonnesBD = json,
|
||||
ListeMembre = new[]
|
||||
{
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.IMAGE$", MembreMob = "DC_Nom", Type = "ChpImage" },
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.DATEIMAGE$", MembreMob = "saDate" },
|
||||
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.NOTEIMAGE$", MembreMob = "saNotes" }
|
||||
}
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, new[] { tableau });
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(2, imageParts);
|
||||
Assert.Equal(2, drawings);
|
||||
Assert.Contains("2024-01-01", texte);
|
||||
Assert.Contains("2024-01-02", texte);
|
||||
Assert.Contains("Note A", texte);
|
||||
Assert.Contains("Note B", texte);
|
||||
Assert.DoesNotContain("$TABPHOTO", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImage_Base64Invalide_PlaceholderSupprimeSansCorruption()
|
||||
{
|
||||
byte[] docx = CreerDocx("Signature: $UNCR.IMAGESIGNATURE$");
|
||||
var champs = new[] { ChampImage("$UNCR.IMAGESIGNATURE$", "###pas-du-base64###", "CR_Signature") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(0, imageParts);
|
||||
Assert.Equal(0, drawings);
|
||||
Assert.DoesNotContain("$UNCR.IMAGESIGNATURE$", texte);
|
||||
Assert.DoesNotContain("pas-du-base64", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImage_RatioImage_Conserve()
|
||||
{
|
||||
string base64 = Convert.ToBase64String(FakePng(256, 100)); // ratio 2.56
|
||||
byte[] docx = CreerDocx("$UNCR.IMAGESIGNATURE$");
|
||||
var champs = new[] { ChampImage("$UNCR.IMAGESIGNATURE$", base64, "CR_Signature") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (_, _, _, extents) = Inspecter(resultat);
|
||||
|
||||
string extent = Assert.Single(extents);
|
||||
string[] parts = extent.Split('x');
|
||||
long cx = long.Parse(parts[0]);
|
||||
long cy = long.Parse(parts[1]);
|
||||
// Largeur bornée à 54,9 mm ; hauteur proportionnelle au ratio 100/256.
|
||||
Assert.Equal(1976400L, cx);
|
||||
Assert.Equal((long)(1976400L * 100.0 / 256.0), cy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImage_ChampTexte_NonRegression()
|
||||
{
|
||||
byte[] docx = CreerDocx("Réf: $REFINTERNE$");
|
||||
var champs = new[]
|
||||
{
|
||||
new RapportChampDto { Id = "1", Rubrique = "$REFINTERNE$", Type = "ChpSimpleChaine", ListeDonnesBD = "ABC-123" }
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(0, imageParts);
|
||||
Assert.Equal(0, drawings);
|
||||
Assert.Contains("ABC-123", texte);
|
||||
Assert.DoesNotContain("$REFINTERNE$", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChpImage_ImagePart_PlaceSousWordMedia()
|
||||
{
|
||||
// La photo doit être placée sous /word/media/ avec une cible RELATIVE :
|
||||
// un ImagePart à la racine (/media/…) avec une cible absolue (/media/…)
|
||||
// est invisible à l'export PDF de LibreOffice (photo absente du PDF).
|
||||
// Le SDK OpenXML 3.x (issue #1730) place les images à la racine ; le
|
||||
// moteur doit donc insérer l'image manuellement sous /word/media/.
|
||||
string base64 = Convert.ToBase64String(FakePng(256, 100));
|
||||
byte[] docx = CreerDocx("$UNPHOTO.IMAGE$");
|
||||
var champs = new[] { ChampImage("$UNPHOTO.IMAGE$", base64, "saNom") };
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
|
||||
using var pkgStream = new MemoryStream(resultat);
|
||||
using Package package = Package.Open(pkgStream, FileMode.Open, FileAccess.Read);
|
||||
|
||||
var mediaParts = package.GetParts()
|
||||
.Where(p => p.Uri.OriginalString.StartsWith("/word/media/", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
Assert.Single(mediaParts);
|
||||
Assert.StartsWith("/word/media/", mediaParts[0].Uri.OriginalString, StringComparison.Ordinal);
|
||||
|
||||
// La cible de la relation image est RELATIVE (jamais /media/… absolue).
|
||||
var imageRelations = package
|
||||
.GetPart(new Uri("/word/document.xml", UriKind.Relative))
|
||||
.GetRelationshipsByType("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image")
|
||||
.ToList();
|
||||
Assert.Single(imageRelations);
|
||||
Assert.False(imageRelations[0].TargetUri.OriginalString.StartsWith("/", StringComparison.Ordinal));
|
||||
Assert.StartsWith("media/", imageRelations[0].TargetUri.OriginalString, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemplirRubriques_DateHeureEtDate_FormatesSelonTypeEtMasque()
|
||||
{
|
||||
// §9/§22 : la même valeur source (31/08/2022 08:00) produit deux rendus.
|
||||
byte[] docx = CreerDocx("Début: $UNRDV.DATEDEBUT$ / Date: $UNRDV.DATEDEBUT_D$");
|
||||
var champs = new[]
|
||||
{
|
||||
new RapportChampDto { Id = "1", Rubrique = "$UNRDV.DATEDEBUT$", Type = "ChpSimpleDateHeure", MasqueSaisie = "JJ/MM/AAAA HH:mm", ListeDonnesBD = "31/08/2022 08:00:00" },
|
||||
new RapportChampDto { Id = "2", Rubrique = "$UNRDV.DATEDEBUT_D$", Type = "ChpSimpleDate", MasqueSaisie = "JJ/MM/AAAA", ListeDonnesBD = "31/08/2022 08:00:00" }
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (_, _, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Contains("31/08/2022 08:00", texte);
|
||||
Assert.Contains("31/08/2022", texte);
|
||||
Assert.DoesNotContain("31/08/2022 08:00:00", texte); // jamais la valeur brute
|
||||
Assert.DoesNotContain("$UNRDV", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batik_MotClefTexte_NonRegression()
|
||||
{
|
||||
// Le moteur partagé doit continuer à substituer les mot-clefs Batik
|
||||
// (<CLIENT.NOM>) en texte, sans jamais les traiter comme des images.
|
||||
byte[] docx = CreerDocx("Client: <CLIENT.NOM>");
|
||||
var champs = new[]
|
||||
{
|
||||
new RapportChampDto { Id = "1", Rubrique = "<CLIENT.NOM>", Type = "ChpSimpleChaine", ListeDonnesBD = "Acme" }
|
||||
};
|
||||
|
||||
byte[] resultat = RapportPdfService.RemplirRubriques(docx, champs);
|
||||
var (imageParts, drawings, texte, _) = Inspecter(resultat);
|
||||
|
||||
Assert.Equal(0, imageParts);
|
||||
Assert.Equal(0, drawings);
|
||||
Assert.Contains("Acme", texte);
|
||||
Assert.DoesNotContain("<CLIENT.NOM>", texte);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de la normalisation CENTRALE des types de champ de rapport
|
||||
/// (<see cref="RapportTypeMembreNormalizer"/>, <see cref="RapportMasqueSaisie"/>)
|
||||
/// et du formatage document (<see cref="RapportChampFormatage"/>) : les deux
|
||||
/// vocabulaires (ChpSimple… et numérique WinDev 20001…/6) doivent produire la
|
||||
/// même catégorie, et les masques WinDev doivent être traduits vers .NET.
|
||||
/// </summary>
|
||||
public class RapportTypeMembreTests
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Normalisation du vocabulaire standard
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("ChpSimpleChaine", RapportTypeMembre.Texte)]
|
||||
[InlineData("ChpSimpleRTF", RapportTypeMembre.Texte)]
|
||||
[InlineData("ChpSimpleDate", RapportTypeMembre.Date)]
|
||||
[InlineData("ChpSimpleDateHeure", RapportTypeMembre.DateHeure)]
|
||||
[InlineData("ChpSimpleBooleen", RapportTypeMembre.Booleen)]
|
||||
[InlineData("ChpSimpleReel", RapportTypeMembre.Numerique)]
|
||||
[InlineData("ChpImage", RapportTypeMembre.Image)]
|
||||
[InlineData("Tableau", RapportTypeMembre.Tableau)]
|
||||
public void Normaliser_VocabulaireStandard(string typeMembre, RapportTypeMembre attendu)
|
||||
=> Assert.Equal(attendu, RapportTypeMembreNormalizer.Normaliser(typeMembre));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Normalisation du vocabulaire numérique WinDev (EXTRA)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("20001", RapportTypeMembre.Texte)] // typTexte
|
||||
[InlineData("20002", RapportTypeMembre.Date)] // typDate
|
||||
[InlineData("20003", RapportTypeMembre.Heure)] // typHeure
|
||||
[InlineData("20004", RapportTypeMembre.Numerique)] // typNum
|
||||
[InlineData("20005", RapportTypeMembre.Monetaire)] // typMonétaire
|
||||
[InlineData("20006", RapportTypeMembre.Duree)] // typDurée
|
||||
[InlineData("6", RapportTypeMembre.Booleen)] // typSélect (Oui/Non)
|
||||
public void Normaliser_VocabulaireNumeriqueWinDev(string typeMembre, RapportTypeMembre attendu)
|
||||
=> Assert.Equal(attendu, RapportTypeMembreNormalizer.Normaliser(typeMembre));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, RapportTypeMembre.Texte)]
|
||||
[InlineData("", RapportTypeMembre.Texte)]
|
||||
[InlineData(" ", RapportTypeMembre.Texte)]
|
||||
[InlineData("TypeInconnu", RapportTypeMembre.Texte)]
|
||||
public void Normaliser_InconnuOuVide_Texte(string? typeMembre, RapportTypeMembre attendu)
|
||||
=> Assert.Equal(attendu, RapportTypeMembreNormalizer.Normaliser(typeMembre));
|
||||
|
||||
[Fact]
|
||||
public void Normaliser_InsensibleALaCasse()
|
||||
=> Assert.Equal(RapportTypeMembre.Date, RapportTypeMembreNormalizer.Normaliser("chpsimpledate"));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Traduction des masques WinDev
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("JJ/MM/AAAA", "dd/MM/yyyy")]
|
||||
[InlineData("JJ/MM/AAAA HH:mm", "dd/MM/yyyy HH:mm")]
|
||||
[InlineData("HH:mm", @"hh\:mm")]
|
||||
[InlineData("99/99/9999", "dd/MM/yyyy")]
|
||||
public void Traduire_MasqueWinDev_VersDotNet(string masque, string attendu)
|
||||
=> Assert.Equal(attendu, RapportMasqueSaisie.Traduire(masque));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("maskAlphaNum")]
|
||||
[InlineData("Inconnu")]
|
||||
public void Traduire_MasqueNonTemporel_Null(string? masque)
|
||||
=> Assert.Null(RapportMasqueSaisie.Traduire(masque));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Formatage document (DonneValeurSelonType)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Formater_Date_MasqueVide_JJMMAAAA()
|
||||
=> Assert.Equal("30/09/2026", RapportChampFormatage.Formater(new DateTime(2026, 9, 30), "ChpSimpleDate"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Date_MasqueRespecte()
|
||||
=> Assert.Equal("30/09/2026", RapportChampFormatage.Formater(new DateTime(2026, 9, 30), "ChpSimpleDate", "JJ/MM/AAAA"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Date_ChaineIso_SansHeure()
|
||||
=> Assert.Equal("01/01/2024", RapportChampFormatage.Formater("2024-01-01 08:00:00", "ChpSimpleDate"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_DateHeure_MasqueVide_JJMMAAAAHHmm()
|
||||
=> Assert.Equal("31/08/2022 08:00", RapportChampFormatage.Formater(new DateTime(2022, 8, 31, 8, 0, 0), "ChpSimpleDateHeure"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_DateHeure_MasqueRespecte()
|
||||
=> Assert.Equal("31/08/2022 08:00", RapportChampFormatage.Formater("2022-08-31 08:00:00", "ChpSimpleDateHeure", "JJ/MM/AAAA HH:mm"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_DateHeure_ChaineFrancaise()
|
||||
=> Assert.Equal("31/08/2022 08:00", RapportChampFormatage.Formater("31/08/2022 08:00:00", "ChpSimpleDateHeure"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Heure_TimeSpan()
|
||||
=> Assert.Equal("14:30", RapportChampFormatage.Formater(new TimeSpan(14, 30, 0), "20003"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Heure_Chaine()
|
||||
=> Assert.Equal("08:30", RapportChampFormatage.Formater("08:30", "20003"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Booleen_Vrai_Oui()
|
||||
=> Assert.Equal("Oui", RapportChampFormatage.Formater(true, "ChpSimpleBooleen"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Booleen_Faux_Non()
|
||||
=> Assert.Equal("Non", RapportChampFormatage.Formater(false, "ChpSimpleBooleen"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Booleen_Chaine_Oui()
|
||||
=> Assert.Equal("Oui", RapportChampFormatage.Formater("1", "6"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_Texte_Brut()
|
||||
=> Assert.Equal("Bonjour", RapportChampFormatage.Formater("Bonjour", "20001"));
|
||||
|
||||
[Fact]
|
||||
public void Formater_DateSentinelle_Null()
|
||||
{
|
||||
// La date sentinelle HFSQL 01/01/1999 signifie « aucune date ».
|
||||
Assert.Null(RapportChampFormatage.Formater(new DateTime(1999, 1, 1), "ChpSimpleDate"));
|
||||
Assert.Null(RapportChampFormatage.Formater(DateTime.MinValue, "ChpSimpleDate"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// §9 : une même valeur source produit deux représentations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MemeValeurSource_DateHeure_Et_Date()
|
||||
{
|
||||
const string source = "31/08/2022 08:00:00";
|
||||
|
||||
// $UNRDV.DATEDEBUT$ (ChpSimpleDateHeure) → date + heure.
|
||||
Assert.Equal("31/08/2022 08:00", RapportChampFormatage.Formater(source, "ChpSimpleDateHeure", "JJ/MM/AAAA HH:mm"));
|
||||
|
||||
// $UNRDV.DATEDEBUT_D$ (ChpSimpleDate) → date seule.
|
||||
Assert.Equal("31/08/2022", RapportChampFormatage.Formater(source, "ChpSimpleDate", "JJ/MM/AAAA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubriqueExtraMobParser_TypeMembreNumerique_PreserveBrut()
|
||||
{
|
||||
string json = "[{\"Nom\":\"type date\",\"Rubrique\":\"$type_date_1$\",\"GroupeApp\":\"EXTRA\",\"TypeMembre\":\"20002\"}]";
|
||||
|
||||
var champs = RubriqueExtraMobParser.Parser(json);
|
||||
var champ = Assert.Single(champs);
|
||||
|
||||
// Le type brut est préservé (source de vérité) ; la normalisation est
|
||||
// déléguée au consommateur.
|
||||
Assert.Equal("20002", champ.Type);
|
||||
Assert.Equal("20002", champ.TypeMembre);
|
||||
Assert.Equal(RapportTypeMembre.Date, RapportTypeMembreNormalizer.Normaliser(champ.TypeMembre));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Contracts.Organisation;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
using Omika.Core.Contracts.Sites;
|
||||
using Omika.Core.Domain;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Clients;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Services;
|
||||
using Omika.Core.Services.Mobile;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du SiteService : les relations du site (contacts, clients, rendez-vous)
|
||||
/// doivent être résolues par LI_PKID numérique, jamais par le GUID public Omika.
|
||||
/// </summary>
|
||||
public class SiteServiceTests
|
||||
{
|
||||
private sealed class FakeSiteRepository : ISiteRepository
|
||||
{
|
||||
public long SiteNumericId { get; set; } = 42;
|
||||
public string? LastContactsId { get; private set; }
|
||||
public string? LastClientsId { get; private set; }
|
||||
|
||||
public Task<Site> CreateAsync(SiteCreateCommand command, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<PagedResult<Site>> SearchAsync(SiteSearchCriteria criteria, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<PagedResult<Site>> SearchDetailsAsync(SiteSearchCriteria criteria, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<Site?> GetDetailsAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = new Site();
|
||||
site.SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: SiteNumericId);
|
||||
return Task.FromResult<Site?>(site);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contact>> GetContactsAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastContactsId = id;
|
||||
return Task.FromResult<IReadOnlyList<Contact>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Client>> GetClientsAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastClientsId = id;
|
||||
return Task.FromResult<IReadOnlyList<Client>>([]);
|
||||
}
|
||||
|
||||
public Task<Site?> UpdateAsync(string id, SiteUpdateCommand command, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeRendezVousService : IRendezVousService
|
||||
{
|
||||
public IReadOnlyList<RendezVousDetailsDto> DetailsResults { get; set; } = [];
|
||||
|
||||
public Task<PagedResult<RendezVousSummaryDto>> SearchAsync(SearchRendezVousRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<PagedResult<RendezVousDetailsDto>> SearchDetailsAsync(SearchRendezVousRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<RendezVousDetailsDto>(
|
||||
DetailsResults, 1, DetailsResults.Count, DetailsResults.Count, DetailsResults.Count == 0 ? 0 : 1));
|
||||
|
||||
public Task<RendezVousDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<RendezVousDetailsDto> CreateAsync(CreateRendezVousRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<RendezVousDetailsDto> UpdateAsync(string id, UpdateRendezVousRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task CancelAsync(string id, AnnulationRendezVousDto annulation, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task ArchiveAsync(string id, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> SetTempsValideAsync(string rendezVousId, string collaborateurId, bool tempsValide, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeCollaborateurService : ICollaborateurService
|
||||
{
|
||||
public Task<PagedResult<CollaborateurSummaryDto>> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<PagedResult<CollaborateurDetailsDto>> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<CollaborateurDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<CollaborateurDetailsDto> CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<CollaborateurDetailsDto> UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousForCollaborateursAsync(IReadOnlyList<string> collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
|
||||
{
|
||||
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
|
||||
=> physical.Guid ?? Guid.NewGuid();
|
||||
|
||||
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
|
||||
=> new PhysicalEntityIdentity { Guid = publicGuid };
|
||||
|
||||
public bool IsCanonical(string entityType, Guid publicGuid) => publicGuid != Guid.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContactsAsync_GuidPublic_ResoutLiPkidAvantLaRequete()
|
||||
{
|
||||
// Arrange : schéma Omika — le resolver retourne un GUID SANS NumericId.
|
||||
var repo = new FakeSiteRepository { SiteNumericId = 42 };
|
||||
var service = new SiteService(
|
||||
repo, new FakeRendezVousService(), new FakeCollaborateurService(), new FakeIdentityResolver());
|
||||
|
||||
// Act : l'identité publique du site est un GUID (comme côté mobile Omika).
|
||||
await service.GetContactsAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
// Assert : le repository a reçu le PK numérique (42), jamais le GUID.
|
||||
Assert.Equal("42", repo.LastContactsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClientsAsync_GuidPublic_ResoutLiPkidAvantLaRequete()
|
||||
{
|
||||
var repo = new FakeSiteRepository { SiteNumericId = 7 };
|
||||
var service = new SiteService(
|
||||
repo, new FakeRendezVousService(), new FakeCollaborateurService(), new FakeIdentityResolver());
|
||||
|
||||
await service.GetClientsAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
Assert.Equal("7", repo.LastClientsId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRendezVousAsync_RdvDateSurAffectation_RestitueLeRdvRabattu()
|
||||
{
|
||||
// Omika : RV_Date (racine) vide → Debut = default. La fiche lieu doit
|
||||
// rabattre la date de l'affectation (RVC_DateDeb) AVANT l'éligibilité,
|
||||
// comme la fiche intervention et le snapshot. Sans cela, un rendez-vous
|
||||
// réellement daté est écarté et l'onglet « Rendez-vous » reste vide.
|
||||
DateTime debutAffectation = new DateTime(2026, 8, 19, 10, 0, 0);
|
||||
var rdvService = new FakeRendezVousService
|
||||
{
|
||||
DetailsResults =
|
||||
[
|
||||
new RendezVousDetailsDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Debut = default,
|
||||
Collaborateurs =
|
||||
[
|
||||
new AffectationCollaborateurDto
|
||||
{
|
||||
PeriodeDebut = debutAffectation,
|
||||
PeriodeFin = debutAffectation.AddHours(1.5)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
var service = new SiteService(
|
||||
new FakeSiteRepository { SiteNumericId = 42 },
|
||||
rdvService,
|
||||
new FakeCollaborateurService(),
|
||||
new FakeIdentityResolver());
|
||||
|
||||
IReadOnlyList<RendezVousDetailsDto> result =
|
||||
await service.GetRendezVousAsync(Guid.NewGuid().ToString("D"));
|
||||
|
||||
RendezVousDetailsDto item = Assert.Single(result);
|
||||
Assert.Equal(debutAffectation, item.Debut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Omika.Core.Compatibility;
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Contracts.Mobile.Sync;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Organisation;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Core.Security;
|
||||
using Omika.Core.Services;
|
||||
|
||||
namespace Omika.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de <see cref="SmtpConfigurationService"/> : la source autoritaire du
|
||||
/// mot de passe SMTP est <c>SMTP.sMdpStandard</c>, jamais l'ancien
|
||||
/// <c>SMTP.sMdp</c>. Le déchiffrement ne dépend PAS du schéma détecté : la
|
||||
/// migration WinDev alimente les deux schémas, et un état de détection non
|
||||
/// « Omika » ne doit plus rendre le mot de passe indisponible (cause du message
|
||||
/// « Configuration SMTP invalide ou incomplète » à l'envoi).
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class SmtpConfigurationServiceTests : IDisposable
|
||||
{
|
||||
private const string Key1 = "K1";
|
||||
private const string Key2 = "K2";
|
||||
private const string Key3 = "K3";
|
||||
private const string Clair = "Omika-Test-SMTP-2026!";
|
||||
|
||||
public SmtpConfigurationServiceTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
}
|
||||
|
||||
// ===== TEST 16 : sMdp volontairement faux + sMdpStandard valide =====
|
||||
// Le mot de passe utilisé provient de sMdpStandard (jamais de sMdp).
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_sMdpStandardValide_UtiliseStandardEtIgnoreLegacy()
|
||||
{
|
||||
string sMdpStandard = EncryptStandard(Clair);
|
||||
string json = $$"""
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdp": "LEGACY_SHOULD_NOT_BE_USED",
|
||||
"sMdpStandard": "{{sMdpStandard}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika)
|
||||
.GetAsync("collab-1", "EMAIL_ENVOI");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(Clair, config!.MotDePasse);
|
||||
}
|
||||
|
||||
// ===== TEST 17 : sMdpStandard absent → erreur claire, aucune tentative legacy =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_sMdpStandardAbsent_LeveErreurClaire()
|
||||
{
|
||||
string json = """
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdp": "LEGACY_SHOULD_NOT_BE_USED"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(
|
||||
() => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI"));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.StartsWith("Configuration SMTP « compte@test.fr » incomplète", ex.Message);
|
||||
// Le message NOMME la cause (mot de passe) et son origine (préférences
|
||||
// utilisateur) : le message générique ne permettait aucune correction.
|
||||
Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("migré", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("préférences de messagerie", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
// Aucune tentative legacy : l'ancien sMdp n'est jamais déchiffré ni exposé.
|
||||
Assert.DoesNotContain("LEGACY_SHOULD_NOT_BE_USED", ex.Message);
|
||||
}
|
||||
|
||||
// ===== sMdpStandard vide → erreur de configuration contrôlée =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_sMdpStandardVide_LeveErreurConfiguration()
|
||||
{
|
||||
string json = """
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdpStandard": ""
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(
|
||||
() => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI"));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ===== sMdpStandard invalide (Base64) → erreur contrôlée, sans secret =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_sMdpStandardInvalide_LeveErreurConfigurationSansSecret()
|
||||
{
|
||||
string json = """
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdpStandard": "%%%"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(
|
||||
() => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI"));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
// Le message utilisateur ne doit jamais contenir de secret ni de détail technique.
|
||||
Assert.DoesNotContain("%%%", ex.Message);
|
||||
Assert.DoesNotContain("sMdpStandard", ex.Message);
|
||||
Assert.DoesNotContain("AES", ex.Message);
|
||||
Assert.DoesNotContain("Base64", ex.Message);
|
||||
}
|
||||
|
||||
// ===== Schéma Batik : sMdpStandard déchiffré comme pour Omika =====
|
||||
|
||||
/// <summary>
|
||||
/// Une base Batik (ou une version de base NON détectée) reçoit elle aussi
|
||||
/// <c>SMTP.sMdpStandard</c>. Conditionner le déchiffrement à
|
||||
/// <c>IsOmika</c> laissait le mot de passe vide : l'envoi échouait alors en
|
||||
/// « Configuration SMTP invalide ou incomplète » alors que la valeur était
|
||||
/// parfaitement lisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_Batik_sMdpStandardPresent_DechiffreLeMotDePasse()
|
||||
{
|
||||
string json = $$"""
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdp": "LEGACY_SHOULD_NOT_BE_USED",
|
||||
"sMdpStandard": "{{EncryptStandard(Clair)}}",
|
||||
"sAdresseServeur": "smtp.test.fr",
|
||||
"sPortServeur": "465",
|
||||
"nCryptage": 3
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Batik)
|
||||
.GetAsync("collab-1", "EMAIL_ENVOI");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(Clair, config!.MotDePasse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration de SOCIÉTÉ lue alors que la base n'est pas détectée comme
|
||||
/// Omika : c'est le cas signalé (envoi avec la configuration de la société qui
|
||||
/// répondait « Configuration SMTP invalide ou incomplète »).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_Batik_ConfigurationSociete_EstUtilisable()
|
||||
{
|
||||
SmtpConfigurationService service = Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Batik,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]);
|
||||
|
||||
SmtpConfiguration? config = await service.GetAsync("42", "EMAIL_ENVOI_1");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(Clair, config!.MotDePasse);
|
||||
Assert.Equal("smtp.societe.fr", config.Serveur);
|
||||
|
||||
// La configuration est réellement envoyable : la validation passe.
|
||||
config.Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sans <c>sMdpStandard</c>, aucun mot de passe n'est exploitable : l'ancien
|
||||
/// <c>sMdp</c> (cryptage WinDev <c>Décrypte(sMdp, gsaCleMdp)</c>) n'est JAMAIS
|
||||
/// déchiffré. L'erreur est contrôlée et ne contient aucun secret.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_Batik_sMdpSeul_LeveErreurControlee()
|
||||
{
|
||||
string json = """
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdp": "LEGACY_SHOULD_NOT_BE_USED"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
EmailEnvoiException ex = await Assert.ThrowsAsync<EmailEnvoiException>(
|
||||
() => Service(json, DatabaseSchemaVersion.Batik).GetAsync("collab-1", "EMAIL_ENVOI"));
|
||||
|
||||
Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type);
|
||||
Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("LEGACY_SHOULD_NOT_BE_USED", ex.Message);
|
||||
}
|
||||
|
||||
// ===== Override .NET explicite : reste prioritaire sur sMdpStandard (Omika) =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_Omika_OverridePrioritaireSurStandard()
|
||||
{
|
||||
string sMdpStandard = EncryptStandard(Clair);
|
||||
string json = $$"""
|
||||
{
|
||||
"SMTP": {
|
||||
"sLogin": "compte@test.fr",
|
||||
"sMdpStandard": "{{sMdpStandard}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika, overridePassword: "OVERRIDE")
|
||||
.GetAsync("collab-1", "EMAIL_ENVOI");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("OVERRIDE", config!.MotDePasse);
|
||||
}
|
||||
|
||||
// ===== Signature / CC automatiques / auto-CCI (règles §4, §8-§11) =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_Omika_SignatureEtCcParses()
|
||||
{
|
||||
string sigHtml = "<p>Ma signature</p>";
|
||||
string sigB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(sigHtml));
|
||||
string json = $$"""
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Principal", "sEmail": "sender@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "login@test.fr",
|
||||
"sMdpStandard": "{{EncryptStandard(Clair)}}",
|
||||
"sAdresseServeur": "smtp.test.fr",
|
||||
"sPortServeur": "465",
|
||||
"nCryptage": 3,
|
||||
"bufSignature": "{{sigB64}}",
|
||||
"bEnCopieCachee": true,
|
||||
"tabCC_CopieEmail": [ "archive@test.fr", "supervision@test.fr" ]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(sigHtml, config!.Signature);
|
||||
Assert.True(config.EnCopieCachee);
|
||||
Assert.Equal(["archive@test.fr", "supervision@test.fr"], config.CcCopieEmail);
|
||||
Assert.Equal("Compte Principal", config.Compte);
|
||||
Assert.Equal("sender@test.fr", config.Email);
|
||||
}
|
||||
|
||||
// TEST 12 : le DTO de sélection expose sCompte + sEmail, jamais host/port.
|
||||
[Fact]
|
||||
public async Task GetListAsync_Dto_ExposeCompteEtEmail_SansServeur()
|
||||
{
|
||||
string json = """
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Principal", "sEmail": "sender@test.fr" },
|
||||
"SMTP": { "sLogin": "login@test.fr", "sAdresseServeur": "smtp.test.fr", "sPortServeur": "465", "nCryptage": 3 }
|
||||
}
|
||||
""";
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("collab-1");
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(list);
|
||||
Assert.Equal("Compte Principal", dto.Libelle);
|
||||
Assert.Equal("sender@test.fr", dto.Email);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Configurations de SOCIÉTÉ (Societe.SO_Parametres)
|
||||
//
|
||||
// Défaut corrigé : la liste n'était construite QUE depuis les lignes
|
||||
// ParametreUtilisateur de l'utilisateur. Une configuration de société qui
|
||||
// n'était pas recopiée dans ces lignes (cas courant : le collaborateur est
|
||||
// rattaché à deux sociétés et n'a aucune ligne EMAIL_ENVOI_<idSociété>)
|
||||
// restait invisible pour le mobile — une seule configuration s'affichait.
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Configuration d'une société, telle que lue dans SO_Parametres.</summary>
|
||||
private static string JsonSociete(
|
||||
string compte = "Compte Société",
|
||||
string email = "societe@test.fr",
|
||||
string login = "societe@test.fr",
|
||||
string? signature = null,
|
||||
bool enCopieCachee = false,
|
||||
string? cc = null,
|
||||
bool inclureMotDePasseStandard = true,
|
||||
string? serveur = "smtp.societe.fr",
|
||||
string? port = "465",
|
||||
int? cryptage = 3)
|
||||
{
|
||||
string sigB64 = signature is null
|
||||
? string.Empty
|
||||
: Convert.ToBase64String(Encoding.UTF8.GetBytes(signature));
|
||||
|
||||
string ccJson = cc is null ? "[]" : $"[\"{cc}\"]";
|
||||
|
||||
var champs = new List<string> { $"\"sLogin\": \"{login}\"" };
|
||||
if (inclureMotDePasseStandard)
|
||||
{
|
||||
champs.Add($"\"sMdpStandard\": \"{EncryptStandard(Clair)}\"");
|
||||
}
|
||||
|
||||
if (serveur is not null)
|
||||
{
|
||||
champs.Add($"\"sAdresseServeur\": \"{serveur}\"");
|
||||
}
|
||||
|
||||
if (port is not null)
|
||||
{
|
||||
champs.Add($"\"sPortServeur\": \"{port}\"");
|
||||
}
|
||||
|
||||
if (cryptage is not null)
|
||||
{
|
||||
champs.Add($"\"nCryptage\": {cryptage}");
|
||||
}
|
||||
|
||||
champs.Add($"\"bufSignature\": \"{sigB64}\"");
|
||||
champs.Add($"\"bEnCopieCachee\": {(enCopieCachee ? "true" : "false")}");
|
||||
champs.Add($"\"tabCC_CopieEmail\": {ccJson}");
|
||||
|
||||
return $$"""
|
||||
{
|
||||
"Compte": { "sCompte": "{{compte}}", "sEmail": "{{email}}" },
|
||||
"SMTP": { {{string.Join(", ", champs)}} }
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une société du collaborateur apparaît MÊME SANS ligne ParametreUtilisateur
|
||||
/// correspondante, avec l'identifiant conventionnel EMAIL_ENVOI_<SO_PKID>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_SocieteSansLigneUtilisateur_ApparaitDansLaListe()
|
||||
{
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())])
|
||||
.GetListAsync("42");
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(list);
|
||||
Assert.Equal("EMAIL_ENVOI_1", dto.Id);
|
||||
Assert.Equal("Compte Société", dto.Libelle);
|
||||
Assert.Equal("societe@test.fr", dto.Email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Société sans compte ni email : elle n'offre aucune configuration d'envoi,
|
||||
/// donc elle n'encombre pas la liste (même règle que l'application de bureau).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_SocieteSansCompte_EstIgnoree()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Compte": { "sCompte": "", "sEmail": "" },
|
||||
"SMTP": { "sLogin": "", "sAdresseServeur": "", "sPortServeur": "", "nCryptage": 0 }
|
||||
}
|
||||
""";
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_3", json)])
|
||||
.GetListAsync("42");
|
||||
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La ligne ParametreUtilisateur de même code NE CRÉE PAS une seconde entrée :
|
||||
/// elle surcharge la configuration de la société (compte, signature, CC, auto-CCI)
|
||||
/// sans toucher au serveur, au login ni au mot de passe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_LigneUtilisateur_SurchargeLaSocieteSansDoublon()
|
||||
{
|
||||
const string surcharge = """
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Renommé", "sEmail": "renomme@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "autre-login@test.fr",
|
||||
"sAdresseServeur": "autre.serveur.fr",
|
||||
"sPortServeur": "25",
|
||||
"nCryptage": 1,
|
||||
"bEnCopieCachee": true,
|
||||
"tabCC_CopieEmail": [ "copie@test.fr" ]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(
|
||||
surcharge,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())],
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", surcharge)])
|
||||
.GetListAsync("42");
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(list);
|
||||
Assert.Equal("EMAIL_ENVOI_1", dto.Id);
|
||||
Assert.Equal("Compte Renommé", dto.Libelle);
|
||||
Assert.Equal("renomme@test.fr", dto.Email);
|
||||
|
||||
// Le serveur et le login restent ceux de la société : la ligne ne peut pas
|
||||
// les changer (règle du bureau), et le mot de passe reste déchiffrable.
|
||||
SmtpConfiguration config = (await Service(
|
||||
surcharge,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())],
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", surcharge)])
|
||||
.GetAsync("42", "EMAIL_ENVOI_1"))!;
|
||||
|
||||
Assert.Equal("smtp.societe.fr", config.Serveur);
|
||||
Assert.Equal(465, config.Port);
|
||||
Assert.Equal("societe@test.fr", config.Login);
|
||||
Assert.Equal(Clair, config.MotDePasse);
|
||||
Assert.True(config.EnCopieCachee);
|
||||
Assert.Equal(["copie@test.fr"], config.CcCopieEmail);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// RÈGLE MÉTIER : pour une configuration de SOCIÉTÉ, toute l'information de
|
||||
// CONNEXION (serveur, port, cryptage, login, mot de passe) provient de
|
||||
// l'enregistrement de la société. La ligne ParametreUtilisateur de même code
|
||||
// n'apporte que le compte affiché, l'adresse expéditrice et la signature — et
|
||||
// ne comble un champ de connexion QUE si l'enregistrement de la société ne le
|
||||
// contient pas (copie complète écrite par l'application de bureau).
|
||||
//
|
||||
// Symptôme corrigé : une configuration de société refusée à l'envoi avec
|
||||
// « Configuration SMTP invalide ou incomplète » alors que la copie côté
|
||||
// utilisateur contenait la valeur manquante.
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Mot de passe absent de l'enregistrement de la société mais présent dans la
|
||||
/// copie utilisateur : l'envoi doit aboutir, avec le serveur et le login de la
|
||||
/// société.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_SocieteSansMotDePasseStandard_MotDePasseReprisDesParametresUtilisateur()
|
||||
{
|
||||
string copieUtilisateur = $$"""
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Société", "sEmail": "societe@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "societe@test.fr",
|
||||
"sMdpStandard": "{{EncryptStandard(Clair)}}",
|
||||
"sAdresseServeur": "smtp.societe.fr",
|
||||
"sPortServeur": "465",
|
||||
"nCryptage": 3
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete(inclureMotDePasseStandard: false))],
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)])
|
||||
.GetAsync("42", "EMAIL_ENVOI_1");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(Clair, config!.MotDePasse);
|
||||
Assert.Equal("smtp.societe.fr", config.Serveur);
|
||||
config.Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serveur / port / cryptage absents de l'enregistrement de la société mais
|
||||
/// présents dans la copie utilisateur : la configuration reste envoyable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_SocieteSansServeur_ServeurReprisDesParametresUtilisateur()
|
||||
{
|
||||
const string copieUtilisateur = """
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Société", "sEmail": "societe@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "societe@test.fr",
|
||||
"sAdresseServeur": "smtp.copie.fr",
|
||||
"sPortServeur": "587",
|
||||
"nCryptage": 2
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes:
|
||||
[
|
||||
new ParametreUtilisateurSmtp(
|
||||
"EMAIL_ENVOI_1", JsonSociete(serveur: null, port: null, cryptage: null))
|
||||
],
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)])
|
||||
.GetAsync("42", "EMAIL_ENVOI_1");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("smtp.copie.fr", config!.Serveur);
|
||||
Assert.Equal(587, config.Port);
|
||||
Assert.Equal(2, config.Cryptage);
|
||||
Assert.Equal(Clair, config.MotDePasse);
|
||||
config.Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une valeur de connexion FOURNIE par la société n'est jamais remplacée par
|
||||
/// celle des paramètres utilisateur (règle explicitement demandée).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_Societe_ServeurPortEtLoginDeLaSocieteNeSontJamaisRemplaces()
|
||||
{
|
||||
const string copieUtilisateur = """
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Renommé", "sEmail": "renomme@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "autre-login@test.fr",
|
||||
"sAdresseServeur": "autre.serveur.fr",
|
||||
"sPortServeur": "25",
|
||||
"nCryptage": 1
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfiguration? config = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())],
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)])
|
||||
.GetAsync("42", "EMAIL_ENVOI_1");
|
||||
|
||||
Assert.NotNull(config);
|
||||
// Connexion : société.
|
||||
Assert.Equal("smtp.societe.fr", config!.Serveur);
|
||||
Assert.Equal(465, config.Port);
|
||||
Assert.Equal(3, config.Cryptage);
|
||||
Assert.Equal("societe@test.fr", config.Login);
|
||||
// Présentation : préférences utilisateur.
|
||||
Assert.Equal("Compte Renommé", config.Compte);
|
||||
Assert.Equal("renomme@test.fr", config.Email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port écrit en NOMBRE et cryptage en CHAÎNE (variantes de sérialisation
|
||||
/// WindDev) : la configuration ne doit pas disparaître pour autant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_PortEnNombreEtCryptageEnChaine_SontAcceptes()
|
||||
{
|
||||
string json = $$"""
|
||||
{
|
||||
"Compte": { "sCompte": "Compte Souple", "sEmail": "souple@test.fr" },
|
||||
"SMTP": {
|
||||
"sLogin": "souple@test.fr",
|
||||
"sMdpStandard": "{{EncryptStandard(Clair)}}",
|
||||
"sAdresseServeur": "smtp.souple.fr",
|
||||
"sPortServeur": 465,
|
||||
"nCryptage": "3"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(
|
||||
await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("42"));
|
||||
|
||||
// Objet unique sous le code EMAIL_ENVOI : configuration personnelle « code
|
||||
// court » (une entrée de tableau donne EMAIL_ENVOI_Perso_N).
|
||||
Assert.Equal("EMAIL_ENVOI", dto.Id);
|
||||
Assert.Equal("Compte Souple", dto.Libelle);
|
||||
|
||||
SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika)
|
||||
.GetAsync("42", "EMAIL_ENVOI");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(465, config!.Port);
|
||||
Assert.Equal(3, config.Cryptage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forme HISTORIQUE (<c>STParamEnvoiEmail</c>) : les champs SMTP sont à la
|
||||
/// RACINE, sans sous-objet « SMTP ». L'application de bureau la convertit à la
|
||||
/// lecture ; le service doit l'accepter au lieu d'ignorer la configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_FormeHistoriqueSansSousObjetSmtp_EstAcceptee()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"sLogin": "ancien@test.fr",
|
||||
"sAdresseServeur": "smtp.ancien.fr",
|
||||
"sPortServeur": "587",
|
||||
"nCryptage": 2
|
||||
}
|
||||
""";
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(
|
||||
await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("42"));
|
||||
|
||||
Assert.Equal("ancien@test.fr", dto.Libelle);
|
||||
Assert.Equal("ancien@test.fr", dto.Email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sélection mémorisée par une version antérieure de l'application
|
||||
/// (identifiant de configuration devenu inconnu) : avec UNE seule
|
||||
/// configuration disponible, c'est forcément celle-là — l'envoi ne doit pas
|
||||
/// échouer sur un « Sélectionnez une configuration SMTP ».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_IdentifiantInconnu_RepliSurLUniqueConfiguration()
|
||||
{
|
||||
SmtpConfiguration? config = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())])
|
||||
.GetAsync("42", "EMAIL_ENVOI_Perso_7");
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("EMAIL_ENVOI_1", config!.Id);
|
||||
Assert.Equal(Clair, config.MotDePasse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avec PLUSIEURS configurations, aucun choix n'est deviné : un identifiant
|
||||
/// inconnu reste introuvable (l'utilisateur doit sélectionner un compte).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_IdentifiantInconnu_PlusieursConfigurations_ResteIntrouvable()
|
||||
{
|
||||
SmtpConfiguration? config = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes:
|
||||
[
|
||||
new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete()),
|
||||
new ParametreUtilisateurSmtp(
|
||||
"EMAIL_ENVOI_3", JsonSociete(compte: "Compte 3", email: "s3@test.fr", login: "s3@test.fr"))
|
||||
])
|
||||
.GetAsync("42", "EMAIL_ENVOI_Perso_7");
|
||||
|
||||
Assert.Null(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sociétés ET configurations personnelles cohabitent : les sociétés d'abord
|
||||
/// (ordre de l'application de bureau), puis les personnelles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_SocietesEtPersonnelles_ToutesExposees()
|
||||
{
|
||||
const string personnelles = """
|
||||
[
|
||||
{ "Compte": { "sCompte": "Perso 1", "sEmail": "perso1@test.fr" },
|
||||
"SMTP": { "sLogin": "perso1@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } },
|
||||
{ "Compte": { "sCompte": "Perso 2", "sEmail": "perso2@test.fr" },
|
||||
"SMTP": { "sLogin": "perso2@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } }
|
||||
]
|
||||
""";
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(
|
||||
personnelles,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes:
|
||||
[
|
||||
new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete()),
|
||||
new ParametreUtilisateurSmtp("EMAIL_ENVOI_3", JsonSociete(compte: "Compte Société 3", email: "s3@test.fr", login: "s3@test.fr"))
|
||||
])
|
||||
.GetListAsync("42");
|
||||
|
||||
Assert.Equal(
|
||||
["EMAIL_ENVOI_1", "EMAIL_ENVOI_3", "EMAIL_ENVOI_Perso_0", "EMAIL_ENVOI_Perso_1"],
|
||||
list.Select(d => d.Id).ToArray());
|
||||
Assert.Equal("Compte Société", list[0].Libelle);
|
||||
Assert.Equal("Compte Société 3", list[1].Libelle);
|
||||
Assert.Equal("Perso 1", list[2].Libelle);
|
||||
Assert.Equal("Perso 2", list[3].Libelle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une ligne <c>EMAIL_ENVOI_<idSoc></c> dont la société n'existe pas (ou
|
||||
/// n'est pas rattachée au collaborateur) ne crée AUCUNE configuration : c'est la
|
||||
/// règle de l'application de bureau (<c>Lib_Email › DonneListeConfigMail</c>), où
|
||||
/// cette ligne ne fait que compléter une configuration de société déjà présente.
|
||||
///
|
||||
/// Sans cette règle, la ligne devenait une configuration autonome — libellée
|
||||
/// comme la société mais SANS serveur, sans login et sans mot de passe — que
|
||||
/// l'utilisateur pouvait sélectionner, pour ne récolter que « Configuration SMTP
|
||||
/// invalide ou incomplète » au moment de l'envoi.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_LigneUtilisateurSansSociete_NEstPasExposee()
|
||||
{
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_7", JsonSociete(compte: "Compte Orphelin"))])
|
||||
.GetListAsync("42");
|
||||
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration personnelle sans login : l'application de bureau exige
|
||||
/// <c>SMTP.sLogin <> ""</c> pour la proposer. Une entrée sans login ne peut
|
||||
/// pas s'authentifier, elle n'est donc pas exposée.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_ConfigurationPersonnelleSansLogin_EstIgnoree()
|
||||
{
|
||||
const string personnelles = """
|
||||
[
|
||||
{ "Compte": { "sCompte": "Sans login", "sEmail": "sanslogin@test.fr" },
|
||||
"SMTP": { "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } },
|
||||
{ "Compte": { "sCompte": "Avec login", "sEmail": "ok@test.fr" },
|
||||
"SMTP": { "sLogin": "ok@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } }
|
||||
]
|
||||
""";
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> list = await Service(personnelles, DatabaseSchemaVersion.Omika)
|
||||
.GetListAsync("42");
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(list);
|
||||
Assert.Equal("Avec login", dto.Libelle);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Préférence de messagerie (CHOIX_MESSAGERIE)
|
||||
//
|
||||
// « Externe » = le collaborateur envoie ses courriels depuis SA messagerie,
|
||||
// hors application. Aucune configuration SMTP interne ne doit alors lui être
|
||||
// exposée, même si des lignes EMAIL_ENVOI_* subsistent (elles aussi peuvent
|
||||
// exister sur des sociétés dont le compte est renseigné).
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Messagerie externe : aucune configuration, ni société ni personnelle.</summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_MessagerieExterne_AucuneConfiguration()
|
||||
{
|
||||
const string personnelles = """
|
||||
[
|
||||
{ "Compte": { "sCompte": "Perso", "sEmail": "perso@test.fr" },
|
||||
"SMTP": { "sLogin": "perso@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } }
|
||||
]
|
||||
""";
|
||||
|
||||
SmtpConfigurationService service = Service(
|
||||
personnelles,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())],
|
||||
preferenceMessagerie: "Externe");
|
||||
|
||||
Assert.Empty(await service.GetListAsync("42"));
|
||||
Assert.True(await service.EstMessagerieExterneAsync("42"));
|
||||
|
||||
// Et l'envoi est refusé : aucune configuration ne peut être résolue.
|
||||
Assert.Null(await service.GetAsync("42", "EMAIL_ENVOI_1"));
|
||||
Assert.Null(await service.GetAsync("42", "EMAIL_ENVOI_Perso_0"));
|
||||
}
|
||||
|
||||
/// <summary>Messagerie « Back-Office » (interne) : les configurations sont exposées.</summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_MessagerieBackOffice_ConfigurationsExposees()
|
||||
{
|
||||
SmtpConfigurationService service = Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())],
|
||||
preferenceMessagerie: "Back-Office");
|
||||
|
||||
SmtpConfigurationDto dto = Assert.Single(await service.GetListAsync("42"));
|
||||
Assert.Equal("EMAIL_ENVOI_1", dto.Id);
|
||||
Assert.False(await service.EstMessagerieExterneAsync("42"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Préférence absente : comportement interne habituel (aucune régression pour
|
||||
/// les bases où la ligne CHOIX_MESSAGERIE n'existe pas).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetListAsync_PreferenceAbsente_ResteInterne()
|
||||
{
|
||||
SmtpConfigurationService service = Service(
|
||||
json: null,
|
||||
DatabaseSchemaVersion.Omika,
|
||||
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]);
|
||||
|
||||
Assert.Single(await service.GetListAsync("42"));
|
||||
Assert.False(await service.EstMessagerieExterneAsync("42"));
|
||||
}
|
||||
|
||||
// ===== Construction du service =====
|
||||
|
||||
private static SmtpConfigurationService Service(
|
||||
string? json,
|
||||
DatabaseSchemaVersion version,
|
||||
string? overridePassword = null,
|
||||
IReadOnlyList<ParametreUtilisateurSmtp>? societes = null,
|
||||
IReadOnlyList<ParametreUtilisateurSmtp>? lignesUtilisateur = null,
|
||||
string? preferenceMessagerie = null)
|
||||
{
|
||||
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
|
||||
ApplicationRuntimeContext.SetDetectedVersion(version);
|
||||
|
||||
// La préférence de messagerie arrive dans la MÊME lecture que les
|
||||
// configurations (colonne PU_Code = CHOIX_MESSAGERIE).
|
||||
if (preferenceMessagerie is not null)
|
||||
{
|
||||
lignesUtilisateur =
|
||||
[
|
||||
.. (lignesUtilisateur ?? [new ParametreUtilisateurSmtp("EMAIL_ENVOI", json)]),
|
||||
new ParametreUtilisateurSmtp("CHOIX_MESSAGERIE", preferenceMessagerie)
|
||||
];
|
||||
}
|
||||
|
||||
var repository = new FakeSmtpConfigurationRepository(json, societes, lignesUtilisateur);
|
||||
var collaborateur = new FakeCollaborateurRepository
|
||||
{
|
||||
Liaison = new CollaborateurUtilisateur(1, 42, null, null)
|
||||
};
|
||||
|
||||
var passwords = new SmtpPasswordOptions();
|
||||
if (overridePassword is not null)
|
||||
{
|
||||
passwords.Passwords["compte@test.fr"] = overridePassword;
|
||||
}
|
||||
|
||||
return new SmtpConfigurationService(
|
||||
repository,
|
||||
collaborateur,
|
||||
new FakeIdentityResolver(),
|
||||
NullLogger<SmtpConfigurationService>.Instance,
|
||||
Options.Create(passwords),
|
||||
new SmtpPasswordStandardDecryptor(Key1, Key2, Key3));
|
||||
}
|
||||
|
||||
// ===== Helper : chiffrement C# équivalent à CrypteMdpSMTPStandard =====
|
||||
|
||||
private static string EncryptStandard(string plaintext)
|
||||
{
|
||||
byte[] key = SmtpPasswordStandardDecryptor.DeriveAesKey(Key1, Key2, Key3);
|
||||
using var aes = Aes.Create();
|
||||
aes.KeySize = 256;
|
||||
aes.BlockSize = 128;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = key;
|
||||
aes.GenerateIV();
|
||||
|
||||
using ICryptoTransform encryptor = aes.CreateEncryptor();
|
||||
byte[] clear = Encoding.UTF8.GetBytes(plaintext);
|
||||
byte[] ciphertext = encryptor.TransformFinalBlock(clear, 0, clear.Length);
|
||||
|
||||
byte[] payload = new byte[aes.IV.Length + ciphertext.Length];
|
||||
Buffer.BlockCopy(aes.IV, 0, payload, 0, aes.IV.Length);
|
||||
Buffer.BlockCopy(ciphertext, 0, payload, aes.IV.Length, ciphertext.Length);
|
||||
return Convert.ToBase64String(payload);
|
||||
}
|
||||
|
||||
// ===== Fakes =====
|
||||
|
||||
private sealed class FakeSmtpConfigurationRepository : ISmtpConfigurationRepository
|
||||
{
|
||||
private readonly IReadOnlyList<ParametreUtilisateurSmtp> _lignesUtilisateur;
|
||||
private readonly IReadOnlyList<ParametreUtilisateurSmtp> _lignesSociete;
|
||||
|
||||
public FakeSmtpConfigurationRepository(
|
||||
string? json,
|
||||
IReadOnlyList<ParametreUtilisateurSmtp>? societes = null,
|
||||
IReadOnlyList<ParametreUtilisateurSmtp>? lignesUtilisateur = null)
|
||||
{
|
||||
_lignesUtilisateur = lignesUtilisateur ?? [new ParametreUtilisateurSmtp("EMAIL_ENVOI", json)];
|
||||
_lignesSociete = societes ?? [];
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ParametreUtilisateurSmtp>> GetConfigurationsAsync(
|
||||
long utilisateurId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_lignesUtilisateur);
|
||||
|
||||
public Task<IReadOnlyList<ParametreUtilisateurSmtp>> GetSocieteConfigurationsAsync(
|
||||
long collaborateurId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_lignesSociete);
|
||||
}
|
||||
|
||||
private sealed class FakeCollaborateurRepository : ICollaborateurRepository
|
||||
{
|
||||
public CollaborateurUtilisateur? Liaison { get; set; }
|
||||
|
||||
public Task<PagedResult<Collaborateur>> SearchAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
||||
|
||||
public Task<PagedResult<Collaborateur>> SearchDetailsAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
||||
|
||||
public Task<Collaborateur?> GetDetailsAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Collaborateur?>(null);
|
||||
|
||||
public Task<CollaborateurUtilisateur?> GetUtilisateurByCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Liaison);
|
||||
|
||||
public Task<string?> GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<Collaborateur> CreateAsync(CollaborateurCreateCommand command, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<Collaborateur?> UpdateAsync(string id, CollaborateurUpdateCommand command, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Collaborateur?>(null);
|
||||
|
||||
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<long>>([]);
|
||||
|
||||
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<long>>([]);
|
||||
}
|
||||
|
||||
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
|
||||
{
|
||||
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
|
||||
=> physical.Guid ?? Guid.Empty;
|
||||
|
||||
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
|
||||
=> null;
|
||||
|
||||
public bool IsCanonical(string entityType, Guid publicGuid)
|
||||
=> publicGuid != Guid.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user