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