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();
}
}