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; /// /// 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. /// 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(); } /// Crée un DOCX avec un paragraphe puis un tableau (une ligne par entrée). 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 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("", 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(" + repli texte // ------------------------------------------------------------------ [Fact] public void Batik_SimplePlaceholder_ToujoursRemplace() { var champs = new[] { Champ("", "ChpSimpleChaine", "Acme") }; string html = Rendre(champs, CreerDocx("Client: ")); Assert.Contains("Client: Acme", html); Assert.DoesNotContain("", html); } [Fact] public void Batik_Tableau_RepliTexte_Conserve() { // Tableau Batik : le jeton PARENT est dans le texte (pas de table Word). var champs = new[] { TableauContacts("", "")); Assert.Contains("Dupont", html); Assert.Contains("04 00", html); Assert.DoesNotContain(" est substituée. var champs = new[] { Champ("", "ChpSimpleChaine", "391244") }; string sujet = SubstituerSujet(" - Rapport", champs); Assert.Equal("391244 - Rapport", sujet); } private static string SubstituerSujet(string? sujet, IReadOnlyList champs) { if (string.IsNullOrEmpty(sujet)) { return sujet ?? string.Empty; } var remplacements = new Dictionary(StringComparer.Ordinal); var connus = new HashSet(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> GetAllAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Modele is null ? [] : [Modele]); public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(Modele); public Task GetCorpsDocxAsync(long modeleMailId, CancellationToken cancellationToken = default) => Task.FromResult(CorpsDocx); } private sealed class FakeRapportChampService : Omika.Core.Interfaces.IRapportChampService { public IReadOnlyList Champs { get; set; } = []; public Task> GetChampsAsync(GetRapportChampsRequest request, bool toutesRub = false, CancellationToken cancellationToken = default) => Task.FromResult(Champs); public Task> GetChampsMailAsync(GetRapportChampsRequest request, CancellationToken cancellationToken = default) => Task.FromResult(Champs); } private sealed class FakeInterventionService : Omika.Core.Interfaces.IInterventionService { public Task> SearchAsync(Omika.Core.Contracts.Interventions.SearchInterventionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> SearchDetailsAsync(Omika.Core.Contracts.Interventions.SearchInterventionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task CreateAsync(Omika.Core.Contracts.Interventions.CreateInterventionRequest request, CancellationToken cancellationToken = default, Omika.Core.Domain.AuditActor? auteur = null) => throw new NotSupportedException(); public Task UpdateAsync(string id, Omika.Core.Contracts.Interventions.UpdateInterventionRequest request, CancellationToken cancellationToken = default, Omika.Core.Domain.AuditActor? auteur = null) => throw new NotSupportedException(); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetNotesAsync(string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync(string interventionId, string contenu, Omika.Core.Domain.AuditActor? auteur, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetEmailsAsync(string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddEmailAsync(string interventionId, Omika.Core.Contracts.Documents.CreateEmailInterventionRequest request, Omika.Core.Domain.AuditActor? auteur, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }