using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FrameworkBLL.GOP.Worker.NodeExecutors;
using FrameworkBLL.Promotion;
using FrameworkDAL.CustomCode.Promotion;
using FrameworkDAL.DTO.Promotion;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.DTO.GOP;
using GB5Shared.DTO.Promotion;
using GB5Shared.EventLogPublish;
using GB5Shared.Promotion;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace FrameworkTests;
///
/// Metadata Promotion's three GOP node executors (PromotionExport/Ingest/Import) have no live
/// database or cross-deployment dependency in isolation — IPromotableEntity/IPromotionAssetLinkDAL
/// are both mockable interfaces — so this covers the actual branching logic (validation blocks a
/// promotion, a dedup match is recognized as an update, an insert-vs-update decision is made
/// correctly) without needing a real database, matching FrameworkTests' existing "mock the
/// interfaces, exercise the real logic" convention (see TypeNormalizerTests/MetaReportBLLTests).
///
public class PromotionNodeExecutorTests
{
private static LoginDTO Login() => new() { ClientId = -1, UserId = 7 };
private static GopFlowSnapshotStepDTO Step(string nodeType) => new()
{
NodeId = 1, NodeCode = "test-step", NodeType = nodeType, SnapshotVersion = "1"
};
private static GopExecutionHeaderDTO Header(int executionId = 100) => new()
{
ExecutionId = executionId, FlowId = 1, FlowCode = "TestFlow", SnapshotVersion = "1"
};
private static IServiceProvider ServiceProviderWith(string entityTypeCode, IPromotableEntity entity)
{
var services = new ServiceCollection();
services.AddKeyedSingleton(entityTypeCode, entity);
return services.BuildServiceProvider();
}
[Fact]
public async Task Export_ValidationBlocked_ThrowsAndNeverReturnsABundle()
{
var promotionBll = new Mock();
promotionBll.Setup(b => b.BuildExportBundleAsync("MENU", 42, It.IsAny(), It.IsAny()))
.ThrowsAsync(new InvalidOperationException("Not promotable: references a client-only addon column"));
var executor = new PromotionExportNodeExecutor(promotionBll.Object, NullLogger.Instance);
var inputJson = JsonSerializer.Serialize(new { EntityTypeCode = "MENU", OriginEntityId = 42 });
var ex = await Assert.ThrowsAsync(() =>
executor.ExecuteAsync(Step("PromotionExport"), Header(), inputJson, Login(), CancellationToken.None));
Assert.Contains("addon", ex.Message);
}
[Fact]
public async Task Export_Promotable_ReturnsSerializedBundle()
{
var promotionBll = new Mock();
promotionBll.Setup(b => b.BuildExportBundleAsync("MENU", 42, It.IsAny(), It.IsAny()))
.ReturnsAsync(new PromotionBundleDTO
{
EntityTypeCode = "MENU",
OriginEntityId = 42,
OriginEnvironmentCode = "CLIENT-ACME"
});
var executor = new PromotionExportNodeExecutor(promotionBll.Object, NullLogger.Instance);
var inputJson = JsonSerializer.Serialize(new { EntityTypeCode = "MENU", OriginEntityId = 42 });
var outputJson = await executor.ExecuteAsync(
Step("PromotionExport"), Header(), inputJson, Login(), CancellationToken.None);
var bundle = JsonSerializer.Deserialize(outputJson!)!;
Assert.Equal("MENU", bundle.EntityTypeCode);
Assert.Equal(42, bundle.OriginEntityId);
Assert.Equal("CLIENT-ACME", bundle.OriginEnvironmentCode);
}
[Fact]
public async Task Ingest_NoPriorLink_IsNotFlaggedAsUpdate()
{
var entity = new Mock();
entity.Setup(e => e.ValidateForPromotionAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(PromotionValidationResultDTO.Ok());
var assetLinkDal = new Mock();
assetLinkDal.Setup(d => d.GetByOrigin("CLIENT-ACME", "MENU", 42, It.IsAny(), It.IsAny()))
.ReturnsAsync((PromotionAssetLinkDTO?)null);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new System.Collections.Generic.Dictionary
{
["Promotion:OriginEnvironmentCode"] = "IMPSYS-PARTNERX"
}).Build();
var sp = ServiceProviderWith("MENU", entity.Object);
var executor = new PromotionIngestNodeExecutor(
sp, assetLinkDal.Object, config, NullLogger.Instance);
var bundle = new PromotionBundleDTO { EntityTypeCode = "MENU", OriginEntityId = 42, OriginEnvironmentCode = "CLIENT-ACME" };
var outputJson = await executor.ExecuteAsync(
Step("PromotionIngest"), Header(), JsonSerializer.Serialize(bundle), Login(), CancellationToken.None);
var result = JsonSerializer.Deserialize(outputJson!)!;
Assert.False(result.IsUpdateToExistingAsset);
Assert.Null(result.ExistingDestinationId);
}
[Fact]
public async Task Ingest_PriorLinkExists_IsFlaggedAsUpdateWithExistingDestinationId()
{
var entity = new Mock();
entity.Setup(e => e.ValidateForPromotionAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(PromotionValidationResultDTO.Ok());
var assetLinkDal = new Mock();
assetLinkDal.Setup(d => d.GetByOrigin("CLIENT-ACME", "MENU", 42, It.IsAny(), It.IsAny()))
.ReturnsAsync(new PromotionAssetLinkDTO { PromotionAssetLinkId = 9, DestinationEntityId = 501 });
var config = new ConfigurationBuilder().Build();
var sp = ServiceProviderWith("MENU", entity.Object);
var executor = new PromotionIngestNodeExecutor(
sp, assetLinkDal.Object, config, NullLogger.Instance);
var bundle = new PromotionBundleDTO { EntityTypeCode = "MENU", OriginEntityId = 42, OriginEnvironmentCode = "CLIENT-ACME" };
var outputJson = await executor.ExecuteAsync(
Step("PromotionIngest"), Header(), JsonSerializer.Serialize(bundle), Login(), CancellationToken.None);
var result = JsonSerializer.Deserialize(outputJson!)!;
Assert.True(result.IsUpdateToExistingAsset);
Assert.Equal(501, result.ExistingDestinationId);
}
[Fact]
public async Task Import_MissingDestinationSourceTypeConfig_ThrowsBeforeCallingImport()
{
var entity = new Mock(MockBehavior.Strict);
var assetLinkDal = new Mock(MockBehavior.Strict);
var config = new ConfigurationBuilder().Build(); // Promotion:DestinationSourceType unset
var eventLog = new EventLogPublish(new Dapr.Client.DaprClientBuilder().Build(), NullLogger.Instance);
var sp = ServiceProviderWith("MENU", entity.Object);
var executor = new PromotionImportNodeExecutor(
sp, assetLinkDal.Object, config, eventLog, NullLogger.Instance);
var ingestResult = new PromotionIngestResultDTO
{
Bundle = new PromotionBundleDTO { EntityTypeCode = "MENU", OriginEntityId = 42 }
};
await Assert.ThrowsAsync(() =>
executor.ExecuteAsync(
Step("PromotionImport"), Header(), JsonSerializer.Serialize(ingestResult), Login(), CancellationToken.None));
// Strict mocks with zero setups — reaching either would have thrown a MockException instead,
// proving Import never touches IPromotableEntity/IPromotionAssetLinkDAL when config is missing.
}
[Fact]
public async Task Import_NewAsset_InsertsAssetLink()
{
var entity = new Mock();
entity.Setup(e => e.ImportPromotedAsync(
It.IsAny(), 3, null, It.IsAny(), It.IsAny()))
.ReturnsAsync(777);
var assetLinkDal = new Mock();
assetLinkDal.Setup(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(1);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new System.Collections.Generic.Dictionary
{
["Promotion:DestinationSourceType"] = "3"
}).Build();
var eventLog = new EventLogPublish(new Dapr.Client.DaprClientBuilder().Build(), NullLogger.Instance);
var sp = ServiceProviderWith("MENU", entity.Object);
var executor = new PromotionImportNodeExecutor(
sp, assetLinkDal.Object, config, eventLog, NullLogger.Instance);
var ingestResult = new PromotionIngestResultDTO
{
Bundle = new PromotionBundleDTO { EntityTypeCode = "MENU", OriginEntityId = 42, OriginEnvironmentCode = "CLIENT-ACME" },
ExistingDestinationId = null
};
var outputJson = await executor.ExecuteAsync(
Step("PromotionImport"), Header(), JsonSerializer.Serialize(ingestResult), Login(), CancellationToken.None);
Assert.Contains("777", outputJson);
assetLinkDal.Verify(d => d.Insert(
It.Is(l => l.DestinationEntityId == 777 && l.OriginEntityId == 42),
It.IsAny(), It.IsAny()), Times.Once);
assetLinkDal.Verify(d => d.UpdateAfterPromotion(
It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Import_ExistingAsset_UpdatesRatherThanInserts()
{
var entity = new Mock();
entity.Setup(e => e.ImportPromotedAsync(
It.IsAny(), 3, 501, It.IsAny(), It.IsAny()))
.ReturnsAsync(501); // update path returns the same destination id
var assetLinkDal = new Mock();
assetLinkDal.Setup(d => d.GetByOrigin("CLIENT-ACME", "MENU", 42, It.IsAny(), It.IsAny()))
.ReturnsAsync(new PromotionAssetLinkDTO { PromotionAssetLinkId = 9, DestinationEntityId = 501 });
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new System.Collections.Generic.Dictionary
{
["Promotion:DestinationSourceType"] = "3"
}).Build();
var eventLog = new EventLogPublish(new Dapr.Client.DaprClientBuilder().Build(), NullLogger.Instance);
var sp = ServiceProviderWith("MENU", entity.Object);
var executor = new PromotionImportNodeExecutor(
sp, assetLinkDal.Object, config, eventLog, NullLogger.Instance);
var ingestResult = new PromotionIngestResultDTO
{
Bundle = new PromotionBundleDTO { EntityTypeCode = "MENU", OriginEntityId = 42, OriginEnvironmentCode = "CLIENT-ACME" },
ExistingDestinationId = 501
};
await executor.ExecuteAsync(
Step("PromotionImport"), Header(), JsonSerializer.Serialize(ingestResult), Login(), CancellationToken.None);
assetLinkDal.Verify(d => d.UpdateAfterPromotion(
It.Is(l => l.PromotionAssetLinkId == 9 && l.DestinationEntityId == 501),
It.IsAny(), It.IsAny()), Times.Once);
assetLinkDal.Verify(d => d.Insert(
It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
}