Backend/e-suite.API.Common/e-suite.API.Common.UnitTests/PatchUnitTests.cs

536 lines
15 KiB
C#

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<IEsuiteDatabaseDbContext> _esuiteDatabaseDbContext;
[SetUp]
public void Setup()
{
_esuiteDatabaseDbContext = new Mock<IEsuiteDatabaseDbContext>();
}
[Test]
public void ApplyTo_Throws_WhenTargetIsNull()
{
var patch = new Patch<PatchDto>(new PatchDto { FirstName = "Colin" }, _esuiteDatabaseDbContext.Object);
Assert.ThrowsAsync<ArgumentNullException>(async () => await patch.ApplyTo(null));
}
[Test]
public async Task ApplyTo_IgnoresNullValues()
{
var dto = new PatchDto
{
FirstName = null,
LastName = "Updated"
};
var patch = new Patch<PatchDto>(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<PatchDto>(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<PatchDtoWithExtra>(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<PatchDto>(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<PatchDto>(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<NestedPatchDto>(dto, _esuiteDatabaseDbContext.Object);
var target = new TargetUser();
Assert.ThrowsAsync<InvalidDataException>( async () => await patch.ApplyTo(target));
}
[Test]
public async Task ApplyTo_SetsNavigationAndForeignKey_WhenReferenceResolves()
{
var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new EsuiteDatabaseDbContext(options, _fakeClock);
var domain = new Domain { Id = 10 };
db.Set<Domain>().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<PatchUser>(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<EsuiteDatabaseDbContext>()
.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<PatchUser>(dto, db);
Assert.ThrowsAsync<NotFoundException>(() => 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<EsuiteDatabaseDbContext>()
.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<PatchBadNavOwner>(dto, db);
var ex = Assert.ThrowsAsync<InvalidDataException>(() => 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<EsuiteDatabaseDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new EsuiteDatabaseDbContext(options, _fakeClock);
var domain = new Domain { Id = 5 };
db.Set<Domain>().Add(domain);
await db.SaveChangesAsync();
var owner = new NoFkOwner();
var dto = new PatchNoFkOwner
{
Domain = new GeneralIdRef { Id = 5 }
};
var patch = new Patch<PatchNoFkOwner>(dto, db);
await patch.ApplyTo(owner);
Assert.That(owner.Domain, Is.SameAs(domain));
}
[Test]
public async Task ApplyTo_DoesNotChangeNavigation_WhenDtoValueIsNull()
{
var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new EsuiteDatabaseDbContext(options, _fakeClock);
var domain = new Domain { Id = 3 };
db.Set<Domain>().Add(domain);
await db.SaveChangesAsync();
var user = new User
{
Domain = domain,
DomainId = 3
};
var dto = new PatchUser
{
Domain = null
};
var patch = new Patch<PatchUser>(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<EsuiteDatabaseDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var db = new EsuiteDatabaseDbContext(options, _fakeClock);
var domain = new Domain { Id = 7 };
db.Set<Domain>().Add(domain);
await db.SaveChangesAsync();
var owner = new BrokenFkOwner();
var dto = new PatchBrokenFkOwner
{
Domain = new GeneralIdRef { Id = 7 }
};
var patch = new Patch<PatchBrokenFkOwner>(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<PatchDtoWithBadMap>(dto, _esuiteDatabaseDbContext.Object);
var target = new TargetUser();
var ex = Assert.ThrowsAsync<InvalidDataException>(() => 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<PatchDtoReadOnly>(dto, _esuiteDatabaseDbContext.Object);
var target = new TargetWithReadOnly();
var ex = Assert.ThrowsAsync<InvalidDataException>(() => 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<PatchDtoWritable>(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<PatchDtoWithoutMaps>(dto, _esuiteDatabaseDbContext.Object);
var target = new TargetUser();
var ex = Assert.ThrowsAsync<InvalidDataException>(() => 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<InvalidDataException>(() =>
new Patch<PatchDtoWithoutPatchesAttribute>(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<PatchUserDto>(dto, _esuiteDatabaseDbContext.Object);
var wrongTarget = new WrongTargetType();
var ex = Assert.ThrowsAsync<InvalidDataException>(() =>
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)));
}
}