using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Omika.Api.Services; using Omika.Core.Abstractions; using Omika.Core.Compatibility; using Omika.Core.Constants; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Interventions; using Omika.Core.Contracts.Mobile.Documents; using Omika.Core.Domain; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Runtime; using Omika.Data.Configuration; using Omika.Data.Connection; using Omika.Data.HfSql.Metadata; using Omika.Data.Runtime; namespace Omika.Tests.Api; /// /// Tests HTTP des endpoints « courriel envoyé depuis une MESSAGERIE EXTERNE » : /// /// POST /api/interventions/{id}/emails/envoye-externe ; /// PUT /api/interventions/{id}/emails/{emailId}/envoye-externe. /// /// Ces endpoints n'envoient RIEN : ils enregistrent un courriel déclaré envoyé /// par l'utilisateur depuis sa propre messagerie /// (ME_Envoyer=true, ME_EnvoyerMob=true, ME_Brouillon=false), /// et refusent l'appel quand la messagerie interne (Back-Office) est active. /// [Collection("ApplicationRuntimeContext")] public class InterventionEmailExterneEndpointsTests : IDisposable { private const string SigningKey = "test-signing-key-for-integration-tests-only"; private const string CollaborateurId = "7"; private const string InterventionId = "12345"; private readonly FakeInterventionService _interventionService = new(); private readonly FakePermissionService _permissionService = new(); private readonly FakeSmtpConfigurationService _smtpService = new(); private readonly WebApplicationFactory _factory; public InterventionEmailExterneEndpointsTests() { ApplicationRuntimeContext.Reset(); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Hfsql:Server"] = "test-server", ["Hfsql:DatabaseName"] = "test-db", ["MobileAuth:SigningKey"] = SigningKey }) .Build(); services.AddSingleton(config); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(); services.RemoveAll(); services.AddScoped(_ => _interventionService); services.RemoveAll(); services.AddScoped(_ => _permissionService); services.RemoveAll(); services.AddScoped(_ => _smtpService); services.RemoveAll(); services.AddScoped(); }); }); } public void Dispose() { _factory.Dispose(); ApplicationRuntimeContext.Reset(); } // ===== Authentification / autorisation ===== /// Sans token : refusé avant toute écriture. [Fact] public async Task PostEnvoyeExterne_SansToken_Retourne401() { var client = _factory.CreateClient(); HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/interventions/{InterventionId}/emails/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Null(_interventionService.DernierEnregistrement); } /// Sans le droit d'envoi d'email : refusé. [Fact] public async Task PostEnvoyeExterne_SansDroitEnvoiEmail_Retourne403() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = false; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/interventions/{InterventionId}/emails/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Null(_interventionService.DernierEnregistrement); } // ===== Messagerie externe : enregistrement « envoyé » ===== /// /// Messagerie externe : le courriel est enregistré avec les champs d'un /// courriel ENVOYÉ (ME_Envoyer + ME_EnvoyerMob), sans envoi SMTP. /// [Fact] public async Task PostEnvoyeExterne_MessagerieExterne_EnregistreEnEnvoyeMob() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = true; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/interventions/{InterventionId}/emails/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement; Assert.NotNull(enregistrement); Assert.False(enregistrement!.Brouillon); Assert.True(enregistrement.Envoyer); Assert.True(enregistrement.EnvoyerMob); // Aucun expéditeur connu : l'envoi n'est pas passé par un compte Omika. Assert.Null(enregistrement.AdrExp); Assert.Equal("Intervention du jour", enregistrement.Objet); } /// /// Messagerie interne (Back-Office) : l'endpoint refuse — l'envoi doit passer /// par Omika, jamais être « déclaré » envoyé par le mobile. /// [Fact] public async Task PostEnvoyeExterne_MessagerieInterne_Retourne400EtRienNEstEcrit() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = false; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/interventions/{InterventionId}/emails/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Null(_interventionService.DernierEnregistrement); } /// Sujet et message vides : refusé (même règle que les autres endpoints). [Fact] public async Task PostEnvoyeExterne_SansSujetNiMessage_Retourne400() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = true; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/interventions/{InterventionId}/emails/envoye-externe", new CreateEmailInterventionRequest { Objet = " ", Message = " " }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Null(_interventionService.DernierEnregistrement); } // ===== Messagerie externe : brouillon existant devenu envoyé ===== /// Un brouillon existant est marqué envoyé (même état que la création). [Fact] public async Task PutEnvoyeExterne_MessagerieExterne_MarqueLeBrouillonEnvoye() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = true; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/interventions/{InterventionId}/emails/email-42/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("email-42", _interventionService.DernierEmailId); EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement; Assert.NotNull(enregistrement); Assert.True(enregistrement!.EnvoyerMob); Assert.False(enregistrement.Brouillon); } /// Un courriel déjà envoyé ne peut pas être marqué une seconde fois (409). [Fact] public async Task PutEnvoyeExterne_CourrielDejaEnvoye_Retourne409() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = true; _interventionService.ErreurMajEmail = "Ce courriel a déjà été envoyé : il ne peut plus être modifié."; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/interventions/{InterventionId}/emails/email-42/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } /// Messagerie interne : le marquage externe est refusé. [Fact] public async Task PutEnvoyeExterne_MessagerieInterne_Retourne400() { _permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true; _smtpService.MessagerieExterne = false; HttpClient client = ClientAuthentifie(); HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/interventions/{InterventionId}/emails/email-42/envoye-externe", Demande()); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Null(_interventionService.DernierEnregistrement); } // ===== Aides ===== private static CreateEmailInterventionRequest Demande() => new() { Objet = "Intervention du jour", Message = "Bonjour,\nIntervention terminée.", AdrDestinataires = "client@exemple.fr", AdrCC = "copie@exemple.fr" }; private HttpClient ClientAuthentifie() { HttpClient client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateValidToken()); return client; } private string CreateValidToken() { using IServiceScope scope = _factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); return tokenService.GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "device-abc-123", UserId = "user-1", CollaborateurId = CollaborateurId, NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3" }); } // ===== Fakes ===== private sealed class FakeInterventionService : IInterventionService { public EmailEnregistrement? DernierEnregistrement { get; private set; } public string? DernierEmailId { get; private set; } /// Simule un courriel déjà envoyé (409). public string? ErreurMajEmail { get; set; } public Task EnregistrerNouvelEmailAsync( string interventionId, EmailEnregistrement enregistrement, AuditActor? auteur, CancellationToken cancellationToken = default) { DernierEnregistrement = enregistrement; return Task.FromResult(new EmailInterventionDto { Id = "email-nouveau" }); } public Task EnregistrerEmailExistantAsync( string emailId, EmailEnregistrement enregistrement, CancellationToken cancellationToken = default) { if (ErreurMajEmail is not null) { throw new InvalidOperationException(ErreurMajEmail); } DernierEmailId = emailId; DernierEnregistrement = enregistrement; return Task.FromResult(new EmailInterventionDto { Id = emailId }); } // ---- Membres de IInterventionService non exercés par ces tests ---- public Task> SearchAsync( SearchInterventionsRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task> SearchDetailsAsync( SearchInterventionsRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task GetByIdAsync( string id, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task CreateAsync( CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => Task.FromResult(new InterventionDetailsDto { Id = "new" }); public Task UpdateAsync( string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => Task.FromResult(null); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task> GetNotesAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetNotesAdministrativesAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync( string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new NoteInterneDto()); public Task> GetEmailsAsync( string interventionId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddEmailAsync( string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default) => Task.FromResult(new EmailInterventionDto()); } private sealed class FakePermissionService : IMobilePermissionService { public Dictionary Permissions { get; } = new(); public MobileTokenIdentity? Identity => null; public Task> GetPermissionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Permissions); public Task HasPermissionAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed); } private sealed class FakeSmtpConfigurationService : ISmtpConfigurationService { /// Préférence de messagerie simulée (false = Back-Office interne). public bool MessagerieExterne { get; set; } public Task> GetListAsync( string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task GetAsync( string collaborateurId, string configurationId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task EstMessagerieExterneAsync( string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(MessagerieExterne); } private sealed class FakeMobileDocumentService : IMobileDocumentService { public Task DownloadAsync( Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ResolveDocumentPkAsync( Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(0L); public Task ResolveDocumentFullPathAsync( Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task UploadAsync( DocumentUploadRequest request, string categorieCle, AuditActor? auteur, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetByInterventionAsync( string interventionId, IReadOnlyCollection? allowedCategories, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetByInterventionsAsync( IReadOnlyList numericInterventionIds, IReadOnlyCollection? allowedCategories, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetNotesAsync( Guid documentGuid, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task AddNoteAsync( Guid documentGuid, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer { public Task InitializeAsync(CancellationToken cancellationToken = default) { ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik); return Task.FromResult(Result.Success()); } } private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable { public HfsqlOptions Options => new(); public HfsqlConnectionState State => HfsqlConnectionState.Closed; public void Dispose() { } public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask; public Task CloseAsync() => Task.CompletedTask; public Task ExecuteScalarAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(null); public Task ExecuteNonQueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult(0); public Task>> QueryAsync(string sql, IReadOnlyList? parameters = null, CancellationToken ct = default) => Task.FromResult>>([]); public Task> GetColumnsAsync(string tableName, CancellationToken ct = default) => Task.FromResult>([]); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }