480 lines
20 KiB
C#
480 lines
20 KiB
C#
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();
|
|
}
|
|
}
|