using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FrameworkBLL.Promotion.Adapters; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Promotion; using Microsoft.Extensions.Configuration; using Moq; using Moq.Protected; using Xunit; namespace FrameworkTests; /// /// RemoteHttpPromotionAdapter — reaches a cross-host entity's own PromotionBridge endpoints (e.g. /// TMS's TrainingProgramme adapter on HRFinanceHost) over plain HTTP, since GOP's execution /// pipeline runs only inside FrameworkSL's own process. Covers the BaseUrl resolution per module, /// the Login-header convention (same as JobEngineScheduleClient/the "entitlement-internal" client), /// correct unwrapping of ResponseStandardDTO's Body, and that — unlike PromotionExchangeClient's /// deliberate non-fatal degrade-to-null — a failed remote call propagates as a real exception, since /// a Promotion Import step has no useful fallback when the destination call doesn't work. /// public class RemoteHttpPromotionAdapterTests { private static LoginDTO Login() => new() { ClientId = -1, UserId = 7 }; private static IConfiguration Config() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Promotion:RemoteModules:TMS:BaseUrl"] = "http://hrfinance.test" }) .Build(); private static (Mock Handler, IHttpClientFactory Factory) NewFactory( Func respond) { var handler = new Mock(); handler.Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync((HttpRequestMessage req, CancellationToken _) => respond(req)); var factory = new Mock(); factory.Setup(f => f.CreateClient("promotion-remote")).Returns(() => new HttpClient(handler.Object)); return (handler, factory.Object); } private static HttpResponseMessage SuccessEnvelope(object body) => new(HttpStatusCode.OK) { Content = new StringContent( JsonSerializer.Serialize(new { Status = 200, Body = body }), Encoding.UTF8, "application/json") }; private static HttpResponseMessage FailedEnvelope(string errorBody) => new(HttpStatusCode.OK) { Content = new StringContent( JsonSerializer.Serialize(new { Status = 400, ErrorBody = errorBody }), Encoding.UTF8, "application/json") }; [Fact] public async Task ExportForPromotionAsync_PostsToCorrectPathAndAttachesLoginHeader() { var bundle = new PromotionBundleDTO { EntityTypeCode = "TRAININGPROGRAMME", OriginEntityId = 42, PayloadJson = "{}" }; HttpRequestMessage? captured = null; var (_, factory) = NewFactory(req => { captured = req; return SuccessEnvelope(bundle); }); var adapter = new RemoteHttpPromotionAdapter(factory, Config(), "TRAININGPROGRAMME", "TMS"); var result = await adapter.ExportForPromotionAsync(42, Login(), CancellationToken.None); Assert.Equal("TRAININGPROGRAMME", result.EntityTypeCode); Assert.Equal(42, result.OriginEntityId); Assert.NotNull(captured); Assert.Equal("http://hrfinance.test/PromotionBridge/ExportForPromotion", captured!.RequestUri!.ToString()); Assert.True(captured.Headers.TryGetValues("Login", out var loginValues)); Assert.Contains("\"ClientId\":-1", Assert.Single(loginValues!)); } [Fact] public async Task ValidateForPromotionAsync_ParsesValidationResult() { var (_, factory) = NewFactory(_ => SuccessEnvelope(PromotionValidationResultDTO.Ok())); var adapter = new RemoteHttpPromotionAdapter(factory, Config(), "TRAININGPROGRAMME", "TMS"); var result = await adapter.ValidateForPromotionAsync( new PromotionBundleDTO { EntityTypeCode = "TRAININGPROGRAMME" }, CancellationToken.None); Assert.True(result.IsPromotable); } [Fact] public async Task ImportPromotedAsync_PostsBundleAndReturnsDestinationId() { HttpRequestMessage? captured = null; var (_, factory) = NewFactory(req => { captured = req; return SuccessEnvelope(904); }); var adapter = new RemoteHttpPromotionAdapter(factory, Config(), "TRAININGPROGRAMME", "TMS"); var result = await adapter.ImportPromotedAsync( new PromotionBundleDTO { EntityTypeCode = "TRAININGPROGRAMME" }, destinationSourceType: 3, existingDestinationId: null, Login(), CancellationToken.None); Assert.Equal(904, result); Assert.Equal("http://hrfinance.test/PromotionBridge/ImportPromoted", captured!.RequestUri!.ToString()); } [Fact] public async Task RemoteCallReportsFailure_ThrowsRatherThanDegrading() { var (_, factory) = NewFactory(_ => FailedEnvelope("No IPromotableEntity registered locally for EntityTypeCode 'TRAININGPROGRAMME'.")); var adapter = new RemoteHttpPromotionAdapter(factory, Config(), "TRAININGPROGRAMME", "TMS"); await Assert.ThrowsAsync(() => adapter.ExportForPromotionAsync(42, Login(), CancellationToken.None)); } [Fact] public async Task TransportFailure_ThrowsRatherThanDegrading() { var (_, factory) = NewFactory(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); var adapter = new RemoteHttpPromotionAdapter(factory, Config(), "TRAININGPROGRAMME", "TMS"); await Assert.ThrowsAsync(() => adapter.ExportForPromotionAsync(42, Login(), CancellationToken.None)); } [Fact] public void Constructor_MissingBaseUrlConfig_ThrowsOnFirstCallNotConstruction() { var emptyConfig = new ConfigurationBuilder().Build(); var (_, factory) = NewFactory(_ => SuccessEnvelope(new { })); // Construction itself never touches config — matches every other adapter's constructor // (config is only read per-call, same as JobEngineScheduleClient's own JobEngine:BaseUrl). var adapter = new RemoteHttpPromotionAdapter(factory, emptyConfig, "TRAININGPROGRAMME", "TMS"); Assert.NotNull(adapter); } }