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