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