using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Promotion; using Moq; using Newtonsoft.Json; using TMSBLL.Promotion; using TMSBLL.TrainingProgramme; using TMSDAL.CustomCode.Promotion; using TMSDAL.DTO.TrainingProgramme; using Xunit; namespace TMSTests; /// /// TrainingProgrammePromotionAdapter — the load-bearing behaviour under test is that an update /// (existingDestinationId resolves to a real row) never wipes the destination's own Sessions/ /// ProgrammePrerequisites/SkillMaps/CompetencyMaps, because TrainingProgrammeBLL.SaveTrainingProgramme's /// own update path unconditionally deletes and rebuilds every child table from whatever the /// current DTO's child collections contain. See the adapter's own class header comment. /// public class TrainingProgrammePromotionAdapterTests { private static LoginDTO Login() => new() { ClientId = -1, UserId = 7 }; private static TrainingProgrammeDTO CleanDto(int programmeId = 42) => new() { ProgrammeId = programmeId, ProgrammeCode = "ONBOARD101", ProgrammeTitle = "New Hire Onboarding", TrainingDomainId = 10, DomainCode = "SAFETY", PassMark = 70, DurationHours = 8, MinCapacity = 1, MaxCapacity = 20, ProgrammeVersionNo = "1.0", IsActive = 1, Status = 1 }; private static (Mock bll, Mock domainLookup, Mock programmeLookup) Mocks() => (new Mock(), new Mock(), new Mock()); private static TrainingProgrammePromotionAdapter NewAdapter( (Mock bll, Mock domainLookup, Mock programmeLookup) m) => new(m.bll.Object, m.domainLookup.Object, m.programmeLookup.Object); private static async Task BuildBundle(TrainingProgrammeDTO dto) { var m = Mocks(); m.bll.Setup(b => b.GetTrainingProgramme(dto.ProgrammeId, It.IsAny())) .ReturnsAsync(JsonConvert.SerializeObject(dto)); var adapter = NewAdapter(m); return await adapter.ExportForPromotionAsync(dto.ProgrammeId, Login(), CancellationToken.None); } [Fact] public async Task Validate_CleanProgramme_IsOk() { var bundle = await BuildBundle(CleanDto()); var adapter = NewAdapter(Mocks()); var result = await adapter.ValidateForPromotionAsync(bundle, CancellationToken.None); Assert.True(result.IsPromotable); } [Fact] public async Task Validate_NoResolvableDomainCode_Blocks() { var dto = CleanDto(); dto.DomainCode = null; dto.TrainingDomainId = 0; // export won't attempt a fallback lookup for a zero/sentinel id var bundle = await BuildBundle(dto); var adapter = NewAdapter(Mocks()); var result = await adapter.ValidateForPromotionAsync(bundle, CancellationToken.None); Assert.False(result.IsPromotable); Assert.Contains(result.BlockingReasons, r => r.Contains("DomainCode")); } [Fact] public async Task Import_FreshInsert_ResolvesDomainAndSaves() { var bundle = await BuildBundle(CleanDto()); var m = Mocks(); m.programmeLookup.Setup(l => l.GetProgrammeIdByCode("ONBOARD101", It.IsAny(), It.IsAny())) .ReturnsAsync((int?)null); m.domainLookup.Setup(l => l.GetDomainIdByCode("SAFETY", It.IsAny(), It.IsAny())) .ReturnsAsync(55); TrainingProgrammeDTO? captured = null; m.bll.Setup(b => b.SaveTrainingProgramme(It.IsAny(), It.IsAny())) .Callback((d, _) => { captured = d; d.ProgrammeId = 904; }) .ReturnsAsync("Saved"); var adapter = NewAdapter(m); var newId = await adapter.ImportPromotedAsync(bundle, destinationSourceType: 3, existingDestinationId: null, Login(), CancellationToken.None); Assert.Equal(904, newId); Assert.NotNull(captured); Assert.Equal(55, captured!.TrainingDomainId); Assert.Equal("ONBOARD101", captured.ProgrammeCode); Assert.Equal((byte)3, captured.SourceType); m.bll.Verify(b => b.GetTrainingProgramme(It.IsAny(), It.IsAny()), Times.Never, "a fresh insert has nothing existing to read back before saving"); } [Fact] public async Task Import_ExistingDestination_PreservesDestinationsOwnChildCollections() { var bundle = await BuildBundle(CleanDto()); // The destination's OWN current row already has real children — these must survive // untouched through Import, since SaveTrainingProgramme's update path deletes-then-rebuilds // every child table from whatever the DTO passed to it carries. var existingDestinationDto = CleanDto(programmeId: 700); existingDestinationDto.ProgrammeTitle = "Old Title At Destination"; // will be overwritten by promoted value existingDestinationDto.Sessions = new List { new() { ProgrammeSessionId = 1, SessionCode = "S1", SessionTitle = "Intro" } }; existingDestinationDto.ProgrammePrerequisites = new List { new() { ProgrammePrereqId = 1, ProgrammePrereqCode = "PR1" } }; existingDestinationDto.SkillMaps = new List { new() { ProgrammeSkillMapId = 1, SkillId = 5, SkillCode = "SK1", SkillName = "Skill One" } }; existingDestinationDto.CompetencyMaps = new List { new() { ProgCompetencyMapId = 1, CompetencyId = 9, CompetencyCode = "C1", CompetencyName = "Competency One" } }; var m = Mocks(); m.bll.Setup(b => b.GetTrainingProgramme(700, It.IsAny())) .ReturnsAsync(JsonConvert.SerializeObject(existingDestinationDto)); m.domainLookup.Setup(l => l.GetDomainIdByCode("SAFETY", It.IsAny(), It.IsAny())) .ReturnsAsync(55); TrainingProgrammeDTO? captured = null; m.bll.Setup(b => b.SaveTrainingProgramme(It.IsAny(), It.IsAny())) .Callback((d, _) => captured = d) .ReturnsAsync("Updated"); var adapter = NewAdapter(m); var resultId = await adapter.ImportPromotedAsync(bundle, destinationSourceType: 2, existingDestinationId: 700, Login(), CancellationToken.None); Assert.Equal(700, resultId); Assert.NotNull(captured); // Parent field overwritten with the promoted value... Assert.Equal("New Hire Onboarding", captured!.ProgrammeTitle); // ...but every child collection still carries exactly what the destination already had // (the DTO passed to Save is built by deserializing the destination's own read-back JSON, // then overlaying only parent fields — content survives even though the object graph is a // fresh deserialization, not the same instance as existingDestinationDto). Assert.Single(captured.Sessions!); Assert.Equal("S1", captured.Sessions![0].SessionCode); Assert.Single(captured.ProgrammePrerequisites!); Assert.Equal("PR1", captured.ProgrammePrerequisites![0].ProgrammePrereqCode); Assert.Single(captured.SkillMaps!); Assert.Equal("SK1", captured.SkillMaps![0].SkillCode); Assert.Single(captured.CompetencyMaps!); Assert.Equal("C1", captured.CompetencyMaps![0].CompetencyCode); } [Fact] public async Task Import_ProgrammeCodeAlreadyExistsAtDestination_UpdatesRatherThanDuplicating() { var bundle = await BuildBundle(CleanDto()); var m = Mocks(); m.programmeLookup.Setup(l => l.GetProgrammeIdByCode("ONBOARD101", It.IsAny(), It.IsAny())) .ReturnsAsync(700); m.bll.Setup(b => b.GetTrainingProgramme(700, It.IsAny())) .ReturnsAsync(JsonConvert.SerializeObject(CleanDto(programmeId: 700))); m.domainLookup.Setup(l => l.GetDomainIdByCode("SAFETY", It.IsAny(), It.IsAny())) .ReturnsAsync(55); int? capturedId = null; m.bll.Setup(b => b.SaveTrainingProgramme(It.IsAny(), It.IsAny())) .Callback((d, _) => capturedId = d.ProgrammeId) .ReturnsAsync("Updated"); var adapter = NewAdapter(m); var resultId = await adapter.ImportPromotedAsync(bundle, 3, existingDestinationId: null, Login(), CancellationToken.None); Assert.Equal(700, resultId); Assert.Equal(700, capturedId); } }