using e_suite.API.Common.exceptions; using e_suite.Database.Core; using e_suite.Database.Core.Models; using e_suite.UnitTestCore; using eSuite.Core.Miscellaneous; using Microsoft.EntityFrameworkCore; using Moq; using NUnit.Framework; using System.ComponentModel.DataAnnotations.Schema; using e_suite.API.Common.models; using e_suite.Database.Core.Tables.Domain; using e_suite.Database.Core.Tables.UserManager; namespace e_suite.API.Common.UnitTests; [Patches(typeof(TargetUser))] public class PatchDto { [PatchMap(nameof(TargetUser.FirstName))] public string? FirstName { get; set; } [PatchMap(nameof(TargetUser.LastName))] public string? LastName { get; set; } [PatchMap(nameof(TargetUser.IsActive))] public bool? IsActive { get; set; } } public class TargetUser { public string FirstName { get; set; } = "Original"; public string LastName { get; set; } = "User"; public bool IsActive { get; set; } = false; } [TestFixture] public class PatchUnitTests : TestBase { private Mock _esuiteDatabaseDbContext; [SetUp] public void Setup() { _esuiteDatabaseDbContext = new Mock(); } [Test] public void ApplyTo_Throws_WhenTargetIsNull() { var patch = new Patch(new PatchDto { FirstName = "Colin" }, _esuiteDatabaseDbContext.Object); Assert.ThrowsAsync(async () => await patch.ApplyTo(null)); } [Test] public async Task ApplyTo_IgnoresNullValues() { var dto = new PatchDto { FirstName = null, LastName = "Updated" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); await patch.ApplyTo(target); Assert.That(target.FirstName, Is.EqualTo("Original")); // unchanged Assert.That(target.LastName, Is.EqualTo("Updated")); // patched } [Test] public async Task ApplyTo_UpdatesNonNullValues() { var dto = new PatchDto { FirstName = "Colin", LastName = "Smith" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); await patch.ApplyTo(target); Assert.That(target.FirstName, Is.EqualTo("Colin")); Assert.That(target.LastName, Is.EqualTo("Smith")); } [Patches(typeof(TargetUser))] private class PatchDtoWithExtra { [PatchMap(nameof(TargetUser.FirstName))] public string? FirstName { get; set; } public string? NonExistent { get; set; } } [Test] public async Task ApplyTo_IgnoresPropertiesNotOnTarget() { var dto = new PatchDtoWithExtra { FirstName = "Updated", NonExistent = "Ignored" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); await patch.ApplyTo(target); Assert.That(target.FirstName, Is.EqualTo("Updated")); } private class TargetWithReadOnly { public string FirstName { get; set; } = "Original"; public string ReadOnlyProp => "Fixed"; } [Test] public async Task ApplyTo_UpdatesNullableValueTypes() { var dto = new PatchDto { IsActive = true }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); await patch.ApplyTo(target); Assert.That(target.IsActive, Is.True); } [Test] public async Task ApplyTo_MutatesTargetInPlace() { var dto = new PatchDto { FirstName = "Updated" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); var reference = target; await patch.ApplyTo(target); Assert.That(ReferenceEquals(reference, target), Is.True); Assert.That(target.FirstName, Is.EqualTo("Updated")); } [Patches(typeof(TargetUser))] public class NestedPatchDto { [PatchMap(nameof(TargetUser.FirstName))] public string? FirstName { get; set; } [PatchMap(nameof(TargetUser.FirstName))] //Note incorrectly mapped to make the code trigger exception. public Address? HomeAddress { get; set; } // should trigger exception } public class Address { public string? Street { get; set; } } [Test] public void ApplyTo_Throws_WhenNestedObjectsArePresent() { var dto = new NestedPatchDto { FirstName = "Updated", HomeAddress = new Address { Street = "New Street" } }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); Assert.ThrowsAsync( async () => await patch.ApplyTo(target)); } [Test] public async Task ApplyTo_SetsNavigationAndForeignKey_WhenReferenceResolves() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var domain = new Domain { Id = 10 }; db.Set().Add(domain); await db.SaveChangesAsync(); var user = new User(); // or even the real User if you like var dto = new PatchUser { Domain = new GeneralIdRef { Id = 10 } }; var patch = new Patch(dto, db); await patch.ApplyTo(user); Assert.That(user.Domain, Is.SameAs(domain)); Assert.That(user.DomainId, Is.EqualTo(10)); } [Test] public void ApplyTo_ThrowsNotFound_WhenEntityCannotBeResolved() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var user = new User(); var dto = new PatchUser { Domain = new GeneralIdRef { Id = 999 } }; var patch = new Patch(dto, db); Assert.ThrowsAsync(() => patch.ApplyTo(user)); } public class BadNav { public long Id { get; set; } } public class BadNavOwner { [ForeignKey(nameof(BadNavId))] public BadNav BadNav { get; set; } = null!; public long BadNavId { get; set; } } [Patches(typeof(BadNavOwner))] public class PatchBadNavOwner { [PatchMap(nameof(BadNavOwner.BadNav))] public GeneralIdRef? BadNav { get; set; } } [Test] public void ApplyTo_Throws_WhenNavigationDoesNotImplementIGeneralId() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var owner = new BadNavOwner(); var dto = new PatchBadNavOwner { BadNav = new GeneralIdRef { Id = 1 } }; var patch = new Patch(dto, db); var ex = Assert.ThrowsAsync(() => patch.ApplyTo(owner)); Assert.That(ex.Message, Does.Contain("does not implement IGeneralId")); } public class NoFkOwner { public Domain Domain { get; set; } = null!; } [Patches(typeof(NoFkOwner))] public class PatchNoFkOwner { [PatchMap(nameof(NoFkOwner.Domain))] public GeneralIdRef? Domain { get; set; } } [Test] public async Task ApplyTo_SetsNavigation_WhenForeignKeyMissing() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var domain = new Domain { Id = 5 }; db.Set().Add(domain); await db.SaveChangesAsync(); var owner = new NoFkOwner(); var dto = new PatchNoFkOwner { Domain = new GeneralIdRef { Id = 5 } }; var patch = new Patch(dto, db); await patch.ApplyTo(owner); Assert.That(owner.Domain, Is.SameAs(domain)); } [Test] public async Task ApplyTo_DoesNotChangeNavigation_WhenDtoValueIsNull() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var domain = new Domain { Id = 3 }; db.Set().Add(domain); await db.SaveChangesAsync(); var user = new User { Domain = domain, DomainId = 3 }; var dto = new PatchUser { Domain = null }; var patch = new Patch(dto, db); await patch.ApplyTo(user); Assert.That(user.Domain, Is.SameAs(domain)); Assert.That(user.DomainId, Is.EqualTo(3)); } public class BrokenFkOwner { [ForeignKey("MissingFk")] public Domain Domain { get; set; } = null!; } [Patches(typeof(BrokenFkOwner))] public class PatchBrokenFkOwner { [PatchMap(nameof(BrokenFkOwner.Domain))] public GeneralIdRef? Domain { get; set; } } [Test] public async Task ApplyTo_DoesNotThrow_WhenForeignKeyPropertyMissing() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; using var db = new EsuiteDatabaseDbContext(options, _fakeClock); var domain = new Domain { Id = 7 }; db.Set().Add(domain); await db.SaveChangesAsync(); var owner = new BrokenFkOwner(); var dto = new PatchBrokenFkOwner { Domain = new GeneralIdRef { Id = 7 } }; var patch = new Patch(dto, db); await patch.ApplyTo(owner); Assert.That(owner.Domain, Is.SameAs(domain)); } [Patches(typeof(TargetUser))] private class PatchDtoWithBadMap { [PatchMap("DoesNotExist")] public string? FirstName { get; set; } } [Test] public void ApplyTo_Throws_WhenPatchMapTargetsUnknownProperty() { var dto = new PatchDtoWithBadMap { FirstName = "Updated" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); var ex = Assert.ThrowsAsync(() => patch.ApplyTo(target)); Assert.That(ex.Message, Does.Contain("PatchMap refers to unknown property 'DoesNotExist'")); Assert.That(ex.Message, Does.Contain(nameof(TargetUser))); } [Patches(typeof(TargetWithReadOnly))] private class PatchDtoReadOnly { [PatchMap(nameof(TargetWithReadOnly.ReadOnlyProp))] public string? ReadOnlyProp { get; set; } } [Test] public void ApplyTo_Throws_WhenTargetPropertyIsNotWritable() { var dto = new PatchDtoReadOnly { ReadOnlyProp = "NewValue" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetWithReadOnly(); var ex = Assert.ThrowsAsync(() => patch.ApplyTo(target)); Assert.That(ex.Message, Does.Contain("is not writable")); Assert.That(ex.Message, Does.Contain(nameof(TargetWithReadOnly.ReadOnlyProp))); } private class TargetWritable { public string FirstName { get; set; } = "Original"; } [Patches(typeof(TargetWritable))] private class PatchDtoWritable { [PatchMap(nameof(TargetWritable.FirstName))] public string? FirstName { get; set; } } [Test] public async Task ApplyTo_DoesNotThrow_WhenTargetPropertyIsWritable() { var dto = new PatchDtoWritable { FirstName = "Updated" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetWritable(); // Act + Assert: should NOT throw Assert.DoesNotThrowAsync(() => patch.ApplyTo(target)); // And the property should be updated Assert.That(target.FirstName, Is.EqualTo("Updated")); } [Patches(typeof(TargetUser))] private class PatchDtoWithoutMaps { public string? FirstName { get; set; } public string? LastName { get; set; } } [Test] public void ApplyTo_Throws_WhenDtoHasNoPatchMapAttributes() { var dto = new PatchDtoWithoutMaps { FirstName = "Colin", LastName = "Smith" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var target = new TargetUser(); var ex = Assert.ThrowsAsync(() => patch.ApplyTo(target)); Assert.That(ex.Message, Does.Contain("does not define any properties with PatchMapAttribute")); } private class PatchDtoWithoutPatchesAttribute { [PatchMap("FirstName")] public string? FirstName { get; set; } } [Test] public void Constructor_Throws_WhenPatchesAttributeIsMissing() { var dto = new PatchDtoWithoutPatchesAttribute { FirstName = "Colin" }; var ex = Assert.Throws(() => new Patch(dto, _esuiteDatabaseDbContext.Object)); Assert.That(ex.Message, Does.Contain("is missing required [Patches] attribute")); } [Patches(typeof(TargetUser))] private class PatchUserDto { [PatchMap(nameof(TargetUser.FirstName))] public string? FirstName { get; set; } } private class WrongTargetType { public string FirstName { get; set; } = "Original"; } [Test] public void ApplyTo_Throws_WhenAppliedToWrongTargetType() { var dto = new PatchUserDto { FirstName = "Updated" }; var patch = new Patch(dto, _esuiteDatabaseDbContext.Object); var wrongTarget = new WrongTargetType(); var ex = Assert.ThrowsAsync(() => patch.ApplyTo(wrongTarget)); Assert.That(ex.Message, Does.Contain("is defined to patch")); Assert.That(ex.Message, Does.Contain(nameof(TargetUser))); Assert.That(ex.Message, Does.Contain(nameof(WrongTargetType))); } }