Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -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();
}
}