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,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/&lt;head&gt;/&lt;style&gt; 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
/// (&lt;table&gt;/&lt;tr&gt;/&lt;td&gt;/&lt;p&gt;/&lt;img&gt;), 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>&lt;!DOCTYPE&gt;</c>,
/// <c>&lt;head&gt;</c> + <c>&lt;style&gt;</c> (classes .Normal/.TableauNormal/.xtab/.xcenter/.xleft/.xright),
/// <c>&lt;body&gt;</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 → &lt;img&gt; 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_&lt;SO_PKID&gt;.
/// </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_&lt;idSoc&gt;</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 &lt;&gt; ""</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;
}
}