Added a fleshed out patch mechanism and implemented two Rest Patch methods.
This commit is contained in:
parent
ef42434d60
commit
90b23f939c
@ -0,0 +1,50 @@
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.Database.Core;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace e_suite.API.Common.UnitTests;
|
||||
|
||||
[TestFixture]
|
||||
public class PatchFactoryUnitTests
|
||||
{
|
||||
[Test]
|
||||
public void Create_ReturnsPatchInstanceWithCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var esuiteDatabaseDbContext = new Mock<IEsuiteDatabaseDbContext>();
|
||||
var factory = new PatchFactory(esuiteDatabaseDbContext.Object);
|
||||
var dto = new PatchUserProfile
|
||||
{
|
||||
FirstName = "Colin"
|
||||
};
|
||||
|
||||
// Act
|
||||
var patch = factory.Create(dto);
|
||||
|
||||
// Assert
|
||||
Assert.That(patch, Is.Not.Null);
|
||||
Assert.That(patch, Is.InstanceOf<IPatch<PatchUserProfile>>());
|
||||
|
||||
// And verify the internal value is the same object
|
||||
var concrete = patch as Patch<PatchUserProfile>;
|
||||
Assert.That(concrete!.Value, Is.EqualTo(dto));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_ReturnsNewInstanceEachTime()
|
||||
{
|
||||
//Arrange
|
||||
var esuiteDatabaseDbContext = new Mock<IEsuiteDatabaseDbContext>();
|
||||
var factory = new PatchFactory(esuiteDatabaseDbContext.Object);
|
||||
var dto = new PatchUserProfile();
|
||||
|
||||
//Act
|
||||
var p1 = factory.Create(dto);
|
||||
var p2 = factory.Create(dto);
|
||||
|
||||
//Assert
|
||||
Assert.That(p1, Is.Not.SameAs(p2));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,293 @@
|
||||
using e_suite.Database.Core.Models;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using NUnit.Framework;
|
||||
using System.Reflection;
|
||||
|
||||
namespace e_suite.API.Common.UnitTests;
|
||||
|
||||
[TestFixture]
|
||||
public class PatchMapImplementationTests
|
||||
{
|
||||
private Assembly _assembly = null!;
|
||||
|
||||
private List<Type> _patchDtoTypes = null!;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_assembly = typeof(Patch<>).Assembly; // e_suite.API.Common
|
||||
|
||||
_patchDtoTypes = _assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.GetCustomAttribute<PatchesAttribute>() != null)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CanDiscoverAllPatchMapAttributesInAssembly()
|
||||
{
|
||||
Assert.That(_patchDtoTypes, Is.Not.Empty, "No types with PatchMapAttribute were found.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPatchMapTargetPropertyNamesMustResolveToRealProperties()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var dtoProp in dtoProps)
|
||||
{
|
||||
var mapAttr = dtoProp.GetCustomAttribute<PatchMapAttribute>();
|
||||
if (mapAttr == null)
|
||||
continue;
|
||||
|
||||
var targetPropName = mapAttr.TargetPropertyName;
|
||||
|
||||
var exists = _assembly
|
||||
.GetTypes()
|
||||
.Any(t => t.GetProperty(
|
||||
targetPropName,
|
||||
BindingFlags.Public | BindingFlags.Instance) != null);
|
||||
|
||||
Assert.That(
|
||||
exists,
|
||||
Is.True,
|
||||
$"PatchMap on {dtoType.Name}.{dtoProp.Name} refers to unknown property '{targetPropName}' in assembly {_assembly.GetName().Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPatchDtoPropertiesMustHavePatchMapAttribute()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var dtoProp in dtoProps)
|
||||
{
|
||||
var hasPatchMap = dtoProp.GetCustomAttribute<PatchMapAttribute>() != null;
|
||||
|
||||
Assert.That(
|
||||
hasPatchMap,
|
||||
Is.True,
|
||||
$"Property '{dtoType.Name}.{dtoProp.Name}' is missing PatchMapAttribute.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPatchMapAttributesMustMapToCompatibleTypes()
|
||||
{
|
||||
var allTypes = _assembly.GetTypes();
|
||||
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var dtoProp in dtoProps)
|
||||
{
|
||||
var mapAttr = dtoProp.GetCustomAttribute<PatchMapAttribute>();
|
||||
if (mapAttr == null)
|
||||
continue;
|
||||
|
||||
var targetPropName = mapAttr.TargetPropertyName;
|
||||
|
||||
// Find the target property anywhere in the assembly
|
||||
var targetProp = allTypes
|
||||
.Select(t => t.GetProperty(targetPropName, BindingFlags.Public | BindingFlags.Instance))
|
||||
.FirstOrDefault(p => p != null);
|
||||
|
||||
Assert.That(
|
||||
targetProp,
|
||||
Is.Not.Null,
|
||||
$"PatchMap on {dtoType.Name}.{dtoProp.Name} refers to unknown property '{targetPropName}'.");
|
||||
|
||||
var dtoPropType = dtoProp.PropertyType;
|
||||
var targetPropType = targetProp!.PropertyType;
|
||||
|
||||
// Nullable<T> → T compatibility
|
||||
if (dtoPropType.IsGenericType && dtoPropType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
dtoPropType = Nullable.GetUnderlyingType(dtoPropType)!;
|
||||
|
||||
if (targetPropType.IsGenericType && targetPropType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
targetPropType = Nullable.GetUnderlyingType(targetPropType)!;
|
||||
|
||||
// Navigation properties (GeneralIdRef → IGeneralId)
|
||||
var isDtoGeneralIdRef = dtoPropType == typeof(GeneralIdRef);
|
||||
var isTargetNavigation = typeof(IGeneralId).IsAssignableFrom(targetPropType);
|
||||
|
||||
if (isDtoGeneralIdRef && isTargetNavigation)
|
||||
continue; // valid navigation mapping
|
||||
|
||||
// Scalar → scalar compatibility
|
||||
Assert.That(
|
||||
targetPropType.IsAssignableFrom(dtoPropType),
|
||||
Is.True,
|
||||
$"PatchMap type mismatch: {dtoType.Name}.{dtoProp.Name} ({dtoProp.PropertyType.Name}) " +
|
||||
$"cannot be assigned to target property {targetProp.DeclaringType!.Name}.{targetProp.Name} ({targetProp.PropertyType.Name}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoPatchDtoMayMapMultiplePropertiesToTheSameTargetProperty()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
// Collect all mappings: targetPropertyName → list of dto properties mapping to it
|
||||
var mapGroups = dtoProps
|
||||
.Select(p => new
|
||||
{
|
||||
Property = p,
|
||||
Attribute = p.GetCustomAttribute<PatchMapAttribute>()
|
||||
})
|
||||
.Where(x => x.Attribute != null)
|
||||
.GroupBy(x => x.Attribute!.TargetPropertyName)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in mapGroups)
|
||||
{
|
||||
if (group.Count() > 1)
|
||||
{
|
||||
var offendingProps = string.Join(
|
||||
", ",
|
||||
group.Select(g => $"{dtoType.Name}.{g.Property.Name}")
|
||||
);
|
||||
|
||||
Assert.Fail(
|
||||
$"Multiple properties in DTO '{dtoType.Name}' map to the same target property '{group.Key}': {offendingProps}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NavigationMappingsMustMapOnlyToIGeneralIdImplementations()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var patchesAttr = dtoType.GetCustomAttribute<PatchesAttribute>();
|
||||
Assert.That(
|
||||
patchesAttr,
|
||||
Is.Not.Null,
|
||||
$"DTO '{dtoType.Name}' is missing required [Patches] attribute.");
|
||||
|
||||
var targetType = patchesAttr!.TargetType;
|
||||
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var dtoProp in dtoProps)
|
||||
{
|
||||
var mapAttr = dtoProp.GetCustomAttribute<PatchMapAttribute>();
|
||||
if (mapAttr == null)
|
||||
continue;
|
||||
|
||||
// Only enforce for navigation DTO properties
|
||||
if (dtoProp.PropertyType != typeof(GeneralIdRef))
|
||||
continue;
|
||||
|
||||
var targetPropName = mapAttr.TargetPropertyName;
|
||||
|
||||
var targetProp = targetType.GetProperty(
|
||||
targetPropName,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
Assert.That(
|
||||
targetProp,
|
||||
Is.Not.Null,
|
||||
$"PatchMap on {dtoType.Name}.{dtoProp.Name} refers to unknown property '{targetPropName}' on target type '{targetType.Name}'.");
|
||||
|
||||
var targetPropType = targetProp!.PropertyType;
|
||||
|
||||
// Unwrap nullable navigation types if needed
|
||||
if (targetPropType.IsGenericType &&
|
||||
targetPropType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
{
|
||||
targetPropType = Nullable.GetUnderlyingType(targetPropType)!;
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
typeof(IGeneralId).IsAssignableFrom(targetPropType),
|
||||
Is.True,
|
||||
$"Navigation mapping error: {dtoType.Name}.{dtoProp.Name} (GeneralIdRef) maps to " +
|
||||
$"{targetType.Name}.{targetProp.Name} ({targetProp.PropertyType.Name}), " +
|
||||
$"which does not implement IGeneralId.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PatchesAttributeMustReferenceAConcretePublicClass()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var patchesAttr = dtoType.GetCustomAttribute<PatchesAttribute>();
|
||||
Assert.That(
|
||||
patchesAttr,
|
||||
Is.Not.Null,
|
||||
$"DTO '{dtoType.Name}' is missing required [Patches] attribute.");
|
||||
|
||||
var targetType = patchesAttr!.TargetType;
|
||||
|
||||
Assert.That(
|
||||
targetType.IsClass,
|
||||
Is.True,
|
||||
$"[Patches] on '{dtoType.Name}' must reference a class, but references '{targetType.Name}'.");
|
||||
|
||||
Assert.That(
|
||||
!targetType.IsAbstract,
|
||||
Is.True,
|
||||
$"[Patches] on '{dtoType.Name}' references abstract type '{targetType.Name}', which cannot be patched.");
|
||||
|
||||
Assert.That(
|
||||
targetType.IsPublic,
|
||||
Is.True,
|
||||
$"[Patches] on '{dtoType.Name}' references non‑public type '{targetType.Name}', which cannot be patched.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPatchMappedTargetPropertiesMustBeWritable()
|
||||
{
|
||||
foreach (var dtoType in _patchDtoTypes)
|
||||
{
|
||||
var patchesAttr = dtoType.GetCustomAttribute<PatchesAttribute>();
|
||||
Assert.That(
|
||||
patchesAttr,
|
||||
Is.Not.Null,
|
||||
$"DTO '{dtoType.Name}' is missing required [Patches] attribute.");
|
||||
|
||||
var targetType = patchesAttr!.TargetType;
|
||||
|
||||
var dtoProps = dtoType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
foreach (var dtoProp in dtoProps)
|
||||
{
|
||||
var mapAttr = dtoProp.GetCustomAttribute<PatchMapAttribute>();
|
||||
if (mapAttr == null)
|
||||
continue;
|
||||
|
||||
var targetPropName = mapAttr.TargetPropertyName;
|
||||
|
||||
var targetProp = targetType.GetProperty(
|
||||
targetPropName,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
Assert.That(
|
||||
targetProp,
|
||||
Is.Not.Null,
|
||||
$"PatchMap on {dtoType.Name}.{dtoProp.Name} refers to unknown property '{targetPropName}' on target type '{targetType.Name}'.");
|
||||
|
||||
Assert.That(
|
||||
targetProp!.CanWrite,
|
||||
Is.True,
|
||||
$"PatchMap on {dtoType.Name}.{dtoProp.Name} refers to non-writable property '{targetProp.Name}' on target type '{targetType.Name}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,536 @@
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\e-suite.Database.Core\e-suite.Database.Core\e-suite.Database.Core.csproj" />
|
||||
<ProjectReference Include="..\..\e-suite.UnitTest.Core\e-suite.UnitTestCore\e-suite.UnitTestCore.csproj" />
|
||||
<ProjectReference Include="..\e-suite.API.Common\e-suite.API.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
7
e-suite.API.Common/e-suite.API.Common/IPatch.cs
Normal file
7
e-suite.API.Common/e-suite.API.Common/IPatch.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
public interface IPatch<T>
|
||||
{
|
||||
T Value { get; }
|
||||
Task ApplyTo(object target);
|
||||
}
|
||||
8
e-suite.API.Common/e-suite.API.Common/IPatchFactory.cs
Normal file
8
e-suite.API.Common/e-suite.API.Common/IPatchFactory.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using e_suite.Database.Core;
|
||||
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
public interface IPatchFactory
|
||||
{
|
||||
IPatch<T> Create<T>(T value);
|
||||
}
|
||||
@ -20,7 +20,8 @@ public interface IUserManager
|
||||
Task DeactivateUser(AuditUserDetails auditUserDetails, GeneralIdRef generalIdRef, CancellationToken cancellationToken = default!);
|
||||
Task<UserProfile> GetProfile(string email, CancellationToken cancellationToken = default!);
|
||||
Task UpdateProfile(AuditUserDetails auditUserDetails, string email, UpdatedUserProfile userProfile, CancellationToken cancellationToken = default!);
|
||||
public Task<IPaginatedData<GetUser>> GetUsersAsync(Paging paging, CancellationToken cancellationToken);
|
||||
Task<LoginResponse> PatchProfile( AuditUserDetails auditUserDetails, string email, PatchUserProfile patchUserProfile, CancellationToken cancellationToken );
|
||||
Task<IPaginatedData<GetUser>> GetUsersAsync(Paging paging, CancellationToken cancellationToken);
|
||||
Task<GetUser?> GetUserAsync(GeneralIdRef generalIdRef, CancellationToken cancellationToken);
|
||||
Task<User> GetUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
@ -34,4 +35,5 @@ public interface IUserManager
|
||||
Task<Guid> CreateSingleUseGuid(AuditUserDetails auditUserDetails, GeneralIdRef generalIdRef, CancellationToken cancellationToken);
|
||||
Task<User?> GetUserWithSingleUseGuid(Guid guid, CancellationToken cancellationToken);
|
||||
Task SetAuthentication( AuditUserDetails auditUserDetails, UserAuthenticationDetails userAuthenticationDetails, bool setEmailConfirmed, CancellationToken cancellationToken );
|
||||
Task PatchUser(AuditUserDetails auditUserDetails, IGeneralIdRef userId, PatchUser patchUser, CancellationToken cancellationToken);
|
||||
}
|
||||
12
e-suite.API.Common/e-suite.API.Common/IocRegistration.cs
Normal file
12
e-suite.API.Common/e-suite.API.Common/IocRegistration.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Autofac;
|
||||
using e_suite.DependencyInjection;
|
||||
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
public class IocRegistration : IIocRegistration
|
||||
{
|
||||
public void RegisterTypes(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<PatchFactory>().As<IPatchFactory>().InstancePerLifetimeScope();
|
||||
}
|
||||
}
|
||||
165
e-suite.API.Common/e-suite.API.Common/Patch.cs
Normal file
165
e-suite.API.Common/e-suite.API.Common/Patch.cs
Normal file
@ -0,0 +1,165 @@
|
||||
using e_suite.API.Common.exceptions;
|
||||
using e_suite.Database.Core;
|
||||
using e_suite.Database.Core.Extensions;
|
||||
using e_suite.Database.Core.Models;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Reflection;
|
||||
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
public class Patch<T> : IPatch<T>
|
||||
{
|
||||
private readonly IEsuiteDatabaseDbContext _dbContext;
|
||||
private readonly Type _targetType;
|
||||
public T Value { get; }
|
||||
|
||||
public Patch(T value, IEsuiteDatabaseDbContext dbContext)
|
||||
{
|
||||
Value = value;
|
||||
_dbContext = dbContext;
|
||||
|
||||
var patchesAttr = typeof(T).GetCustomAttribute<PatchesAttribute>();
|
||||
if (patchesAttr == null)
|
||||
throw new InvalidDataException(
|
||||
$"Patch DTO '{typeof(T).Name}' is missing required [Patches] attribute.");
|
||||
|
||||
_targetType = patchesAttr.TargetType;
|
||||
}
|
||||
|
||||
public async Task ApplyTo(object target)
|
||||
{
|
||||
if (target == null)
|
||||
throw new ArgumentNullException(nameof(target));
|
||||
|
||||
if (!_targetType.IsAssignableFrom(target.GetType()))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch DTO '{typeof(T).Name}' is defined to patch '{_targetType.Name}', " +
|
||||
$"but was applied to instance of '{target.GetType().Name}'.");
|
||||
}
|
||||
|
||||
|
||||
var targetType = target.GetType();
|
||||
var targetProps = targetType
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.ToDictionary(p => p.Name);
|
||||
|
||||
var patchProps = typeof(T)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
var mappedProps = patchProps
|
||||
.Where(p => p.GetCustomAttribute<PatchMapAttribute>() != null)
|
||||
.ToList();
|
||||
|
||||
if (mappedProps.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch DTO '{typeof(T).Name}' does not define any properties with PatchMapAttribute.");
|
||||
}
|
||||
|
||||
foreach (var patchProp in patchProps)
|
||||
{
|
||||
// Only process properties with PatchMap
|
||||
var mapAttr = patchProp.GetCustomAttribute<PatchMapAttribute>();
|
||||
if (mapAttr == null)
|
||||
continue;
|
||||
|
||||
var targetPropName = mapAttr.TargetPropertyName;
|
||||
|
||||
if (!targetProps.TryGetValue(targetPropName, out var targetProp))
|
||||
throw new InvalidDataException(
|
||||
$"PatchMap refers to unknown property '{targetPropName}' on '{targetType.Name}'");
|
||||
|
||||
if (!targetProp.CanWrite)
|
||||
throw new InvalidDataException(
|
||||
$"Target property '{targetPropName}' is not writable");
|
||||
|
||||
var patchValue = patchProp.GetValue(Value);
|
||||
|
||||
// Null means "not included in the patch"
|
||||
if (patchValue == null)
|
||||
continue;
|
||||
|
||||
bool isGeneralIdRef = typeof(IGeneralIdRef).IsAssignableFrom(patchProp.PropertyType);
|
||||
|
||||
// Disallow nested objects (except string and IGeneralIdRef)
|
||||
if (patchProp.PropertyType.IsClass &&
|
||||
patchProp.PropertyType != typeof(string) &&
|
||||
!isGeneralIdRef)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Nested objects are not allowed in PATCH DTOs. Property: {patchProp.Name}");
|
||||
}
|
||||
|
||||
// Handle navigation properties via IGeneralIdRef
|
||||
if (isGeneralIdRef)
|
||||
{
|
||||
// Ensure the target navigation type implements IGeneralId
|
||||
if (!typeof(IGeneralId).IsAssignableFrom(targetProp.PropertyType))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"PatchMap for '{patchProp.Name}' refers to navigation property '{targetPropName}', " +
|
||||
$"but '{targetProp.PropertyType.Name}' does not implement IGeneralId. " +
|
||||
$"Navigation properties patched via IGeneralIdRef must implement IGeneralId.");
|
||||
}
|
||||
|
||||
var refValue = (IGeneralIdRef)patchValue;
|
||||
|
||||
var targetEntityType = targetProp.PropertyType;
|
||||
|
||||
// Get DbSet<TEntity>
|
||||
var setMethod = typeof(DbContext)
|
||||
.GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||
.MakeGenericMethod(targetEntityType);
|
||||
|
||||
var dbSet = setMethod.Invoke(_dbContext, null);
|
||||
|
||||
// Call FindByGeneralIdRefAsync<TEntity>
|
||||
var findMethod = typeof(QueryableExtensions)
|
||||
.GetMethod(nameof(QueryableExtensions.FindByGeneralIdRefAsync))!
|
||||
.MakeGenericMethod(targetEntityType);
|
||||
|
||||
var task = (Task)findMethod.Invoke(
|
||||
null,
|
||||
[dbSet, refValue, CancellationToken.None]
|
||||
)!;
|
||||
|
||||
await task.ConfigureAwait(false);
|
||||
|
||||
var resultProperty = task.GetType().GetProperty("Result");
|
||||
var resolvedEntity = resultProperty?.GetValue(task);
|
||||
|
||||
if (resolvedEntity == null)
|
||||
throw new NotFoundException(
|
||||
$"Unable to resolve reference for '{patchProp.Name}'");
|
||||
|
||||
// Assign navigation property
|
||||
targetProp.SetValue(target, resolvedEntity);
|
||||
|
||||
// Look for ForeignKeyAttribute on the navigation property
|
||||
var fkAttr = targetProp.GetCustomAttribute<ForeignKeyAttribute>();
|
||||
if (fkAttr != null)
|
||||
{
|
||||
var fkName = fkAttr.Name;
|
||||
|
||||
if (targetProps.TryGetValue(fkName, out var fkProp))
|
||||
{
|
||||
var idValue = resolvedEntity
|
||||
.GetType()
|
||||
.GetProperty("Id")
|
||||
?.GetValue(resolvedEntity);
|
||||
|
||||
fkProp.SetValue(target, idValue);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default scalar assignment
|
||||
targetProp.SetValue(target, patchValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
e-suite.API.Common/e-suite.API.Common/PatchFactory.cs
Normal file
17
e-suite.API.Common/e-suite.API.Common/PatchFactory.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using e_suite.Database.Core;
|
||||
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
public class PatchFactory : IPatchFactory
|
||||
{
|
||||
private readonly IEsuiteDatabaseDbContext _dbContext;
|
||||
public PatchFactory(IEsuiteDatabaseDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public IPatch<T> Create<T>(T value)
|
||||
{
|
||||
return new Patch<T>(value, _dbContext);
|
||||
}
|
||||
}
|
||||
12
e-suite.API.Common/e-suite.API.Common/PatchMapAttribute.cs
Normal file
12
e-suite.API.Common/e-suite.API.Common/PatchMapAttribute.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class PatchMapAttribute : Attribute
|
||||
{
|
||||
public string TargetPropertyName { get; }
|
||||
|
||||
public PatchMapAttribute(string targetPropertyName)
|
||||
{
|
||||
TargetPropertyName = targetPropertyName;
|
||||
}
|
||||
}
|
||||
12
e-suite.API.Common/e-suite.API.Common/PatchesAttribute.cs
Normal file
12
e-suite.API.Common/e-suite.API.Common/PatchesAttribute.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace e_suite.API.Common;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
|
||||
public sealed class PatchesAttribute : Attribute
|
||||
{
|
||||
public Type TargetType { get; }
|
||||
|
||||
public PatchesAttribute(Type targetType)
|
||||
{
|
||||
TargetType = targetType;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\e-suite.Database.Core\e-suite.Database.Core\e-suite.Database.Core.csproj" />
|
||||
<ProjectReference Include="..\..\e-suite.DependencyInjection\e-suite.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\e-suite.Utilities.Pagination\e-suite.Utilities.Pagination\e-suite.Utilities.Pagination.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
using System.ComponentModel;
|
||||
using e_suite.API.Common.models.@base;
|
||||
using e_suite.Database.Core.Tables.UserManager;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using e_suite.API.Common.models.@base;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
|
||||
namespace e_suite.API.Common.models;
|
||||
|
||||
@ -11,5 +12,24 @@ public class EditUser : UserBase
|
||||
[JsonPropertyName("id")]
|
||||
[Required]
|
||||
[DefaultValue(null)]
|
||||
public GeneralIdRef? Id { get; set; }
|
||||
public GeneralIdRef Id { get; set; } = null!;
|
||||
}
|
||||
|
||||
[Patches(typeof(User))]
|
||||
public class PatchUser
|
||||
{
|
||||
[PatchMap(nameof(User.FirstName))]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[PatchMap(nameof(User.LastName))]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[PatchMap(nameof(User.MiddleNames))]
|
||||
public string? MiddleNames { get; set; }
|
||||
|
||||
[PatchMap(nameof(User.Email))]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[PatchMap(nameof(User.Domain))]
|
||||
public GeneralIdRef? Domain { get; set; }
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using e_suite.Database.Core.Tables.UserManager;
|
||||
|
||||
namespace e_suite.API.Common.models;
|
||||
|
||||
@ -52,6 +53,9 @@ public class UserProfile
|
||||
public string SsoSubject { get; set; } = string.Empty;
|
||||
|
||||
public Dictionary<long, string> SsoProviders { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("preferredLocale")]
|
||||
public string PreferredLocale { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
@ -79,4 +83,27 @@ public class UpdatedUserProfile
|
||||
|
||||
[JsonPropertyName("securityCode")]
|
||||
public string SecurityCode { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("preferredLocale")]
|
||||
public string PreferredLocale { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[Patches(typeof(User))]
|
||||
public class PatchUserProfile
|
||||
{
|
||||
[JsonPropertyName("firstName")]
|
||||
[PatchMap(nameof(User.FirstName))]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("middleNames")]
|
||||
[PatchMap(nameof(User.MiddleNames))]
|
||||
public string? MiddleNames { get; set; }
|
||||
|
||||
[JsonPropertyName("lastName")]
|
||||
[PatchMap(nameof(User.LastName))]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[JsonPropertyName("preferredLocale")]
|
||||
[PatchMap(nameof(User.PreferredLocale))]
|
||||
public string? PreferredLocale { get; set; }
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace e_suite.API.Common.models.@base;
|
||||
|
||||
public abstract class UserBase
|
||||
public class UserBase
|
||||
{
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.UnitTestCore;
|
||||
using eSuite.API.Controllers;
|
||||
using eSuite.API.SingleSignOn;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
@ -12,6 +13,7 @@ public abstract class ProfileControllerTestBase : TestBase
|
||||
{
|
||||
protected ProfileController _profileController = null!;
|
||||
protected Mock<IUserManager> _userManagerMock = null!;
|
||||
protected Mock<ICookieManager> _cookieManagerMock = null!;
|
||||
|
||||
protected const string AccessDeniedText = "Access denied";
|
||||
protected const string BadRequestText = "Bad request";
|
||||
@ -23,8 +25,9 @@ public abstract class ProfileControllerTestBase : TestBase
|
||||
await base.Setup();
|
||||
|
||||
_userManagerMock = new Mock<IUserManager>();
|
||||
_cookieManagerMock = new Mock<ICookieManager>();
|
||||
|
||||
_profileController = new ProfileController(_userManagerMock.Object);
|
||||
_profileController = new ProfileController(_userManagerMock.Object, _cookieManagerMock.Object);
|
||||
}
|
||||
|
||||
protected void AddAuthorisedUserToController(long id, string email, string displayName)
|
||||
|
||||
@ -159,10 +159,7 @@ public class AccountController : ESuiteController
|
||||
[Route("auth/{ssoId}")]
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
#pragma warning disable IDE0060 // Remove unused parameter
|
||||
// ReSharper disable once IdentifierTypo
|
||||
public async Task<IActionResult> Auth([FromRoute] long ssoId, [FromQuery] string code, [FromQuery] string scope, [FromQuery] string authuser, [FromQuery] string prompt, CancellationToken cancellationToken)
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
{
|
||||
var ssoUserId = await _singleSignOn.ExchangeAuthorisationToken(ssoId, code, cancellationToken);
|
||||
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.models;
|
||||
using eSuite.API.security;
|
||||
using eSuite.API.SingleSignOn;
|
||||
using eSuite.API.Utilities;
|
||||
using eSuite.Core.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
|
||||
namespace eSuite.API.Controllers;
|
||||
|
||||
@ -15,14 +17,16 @@ namespace eSuite.API.Controllers;
|
||||
public class ProfileController : ESuiteControllerBase
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ICookieManager _cookieManager;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userManager"></param>
|
||||
public ProfileController(IUserManager userManager)
|
||||
public ProfileController(IUserManager userManager, ICookieManager cookieManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_cookieManager = cookieManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -60,4 +64,26 @@ public class ProfileController : ESuiteControllerBase
|
||||
await _userManager.UpdateProfile(AuditUserDetails, User.Email(), userProfile, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /// Patching is useful when you only want to update a few fields of the user rather than the whole object.
|
||||
/// </summary>
|
||||
/// <param name="patch"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
[Route("myProfile")]
|
||||
[HttpPatch]
|
||||
[AccessKey(SecurityAccess.Everyone)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> PatchMyProfile(
|
||||
[FromBody] PatchUserProfile patchUserProfile,
|
||||
CancellationToken cancellationToken = default!
|
||||
)
|
||||
{
|
||||
var loginResponse = await _userManager.PatchProfile(AuditUserDetails, User.Email(), patchUserProfile, cancellationToken);
|
||||
|
||||
await _cookieManager.CreateSessionCookie(Response, loginResponse);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.API.Common.models.@base;
|
||||
using e_suite.Database.Core.Models;
|
||||
using e_suite.Utilities.Pagination;
|
||||
using eSuite.API.Models;
|
||||
@ -8,6 +9,8 @@ using eSuite.API.Utilities;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using eSuite.Core.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using IRoleManager = e_suite.API.Common.IRoleManager;
|
||||
|
||||
namespace eSuite.API.Controllers;
|
||||
@ -86,6 +89,24 @@ public class UserController : ESuiteControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patching is useful when you only want to update a few fields of the user rather than the whole object.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
[Route("user")]
|
||||
[HttpPatch]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[AccessKey(SecurityAccess.EditUser)]
|
||||
public async Task<IActionResult> PatchUser([FromQuery] IGeneralIdRef userId, [FromBody] PatchUser patchUser, CancellationToken cancellationToken = default!)
|
||||
{
|
||||
|
||||
await _userManager.PatchUser(AuditUserDetails, userId, patchUser, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new e-suite user
|
||||
/// </summary>
|
||||
|
||||
@ -17,7 +17,8 @@ internal static class UserProfileExtensions
|
||||
DomainSsoProviderId = userProfile.DomainSsoProviderId,
|
||||
SsoProviderId = userProfile.SsoProviderId ?? -1,
|
||||
SsoSubject = userProfile.SsoSubject,
|
||||
SsoProviders = userProfile.SsoProviders
|
||||
SsoProviders = userProfile.SsoProviders,
|
||||
PreferredLocale = userProfile.PreferredLocale
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -87,4 +87,7 @@ public class UserProfile : IPasswordInformation
|
||||
/// List of SSO Providers that the user can select.
|
||||
/// </summary>
|
||||
public Dictionary<long, string> SsoProviders { get; set; } = [];
|
||||
|
||||
[Display(Name = "Preferred Locale")]
|
||||
public string PreferredLocale { get; set; }
|
||||
}
|
||||
|
||||
@ -23,6 +23,14 @@ public class EsuiteDatabaseDbContext : SunDatabaseEntityContext, IEsuiteDatabase
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable Set(Type entityType)
|
||||
{
|
||||
return (IQueryable)typeof(DbContext)
|
||||
.GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||
.MakeGenericMethod(entityType)
|
||||
.Invoke(this, null)!;
|
||||
}
|
||||
|
||||
public DbSet<User> Users { get; set; } = null!;
|
||||
public DbSet<SsoProvider> SsoProviders { get; set; } = null!;
|
||||
public DbSet<SingleUseGuid> SingleUseGuids { get; set; }
|
||||
|
||||
@ -17,5 +17,5 @@ namespace e_suite.Database.Core;
|
||||
|
||||
public interface IEsuiteDatabaseDbContext : IAudit, ISentinel, IUserManager, IDiagnostics, ISequences, ICustomFields, IForm, IGlossaries, IPrinter, IContact, IMail, IDomain, IMiscellaneous, IWorkflowManager
|
||||
{
|
||||
|
||||
IQueryable Set(Type entityType);
|
||||
}
|
||||
@ -81,6 +81,11 @@ public class User : IPassword, IEmailAddress, IGeneralId
|
||||
[ForeignKey(nameof(SsoProviderId))]
|
||||
public virtual SsoProvider? SsoProvider { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MaxLength(20)]
|
||||
[DefaultValue("en-GB")]
|
||||
public string PreferredLocale { get; set; } = "en-GB";
|
||||
|
||||
public static void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<User>().HasData(new User
|
||||
@ -100,7 +105,8 @@ public class User : IPassword, IEmailAddress, IGeneralId
|
||||
DomainId = 1,
|
||||
SsoProviderId = null,
|
||||
SsoSubject = string.Empty,
|
||||
Guid = new Guid("{30cfcd5b-3385-43f1-b59a-fd35236f3d92}")
|
||||
Guid = new Guid("{30cfcd5b-3385-43f1-b59a-fd35236f3d92}"),
|
||||
PreferredLocale = "en-GB"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
2082
e-suite.Database.SqlServer/e-suite.Database.SqlServer/Migrations/20260127230112_PreferredLocale.Designer.cs
generated
Normal file
2082
e-suite.Database.SqlServer/e-suite.Database.SqlServer/Migrations/20260127230112_PreferredLocale.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace esuite.Database.SqlServer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PreferredLocale : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PreferredLocale",
|
||||
schema: "UserManager",
|
||||
table: "Users",
|
||||
type: "nvarchar(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
schema: "UserManager",
|
||||
table: "Users",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1L,
|
||||
column: "PreferredLocale",
|
||||
value: "en-GB");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PreferredLocale",
|
||||
schema: "UserManager",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ namespace esuite.Database.SqlServer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.12")
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
@ -1503,6 +1503,11 @@ namespace esuite.Database.SqlServer.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PreferredLocale")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<long?>("SsoProviderId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@ -1546,6 +1551,7 @@ namespace esuite.Database.SqlServer.Migrations
|
||||
LastUpdated = new DateTimeOffset(new DateTime(2022, 6, 9, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
MiddleNames = "",
|
||||
Password = "AgAAAAIAACcQAAAAAAAAABDGmF9Hre4l8k9lkZAxBRJk0zNHD2b6xfKz4C7h6UjuirkoyoP2fVR6d9b7riT03UnL5yAgFh2pSSVQDx+nQ5PBjZRB9UG4u5FrY8W7ouA/+w==",
|
||||
PreferredLocale = "en-GB",
|
||||
SsoSubject = "",
|
||||
TwoFactorAuthenticationKey = "4EHHG42OWCN3L72TSRYSHTV6MRJXVOY3",
|
||||
UsingTwoFactorAuthentication = false
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.repository;
|
||||
using e_suite.Database.Audit;
|
||||
using e_suite.Database.Audit.AuditEngine;
|
||||
using e_suite.Database.Core.Tables.CustomFields;
|
||||
using e_suite.UnitTestCore;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
@ -70,4 +71,9 @@ public class FakeCustomFieldRepository : ICustomFieldRepository
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<int> AddAdhocAuditEntry(AuditUserDetails auditUserDetails, AuditType auditType, Dictionary<string, Change> fields, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,9 @@
|
||||
"C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.Database.Core\\e-suite.Database.Core\\e-suite.Database.Core.csproj": {
|
||||
"projectPath": "C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.Database.Core\\e-suite.Database.Core\\e-suite.Database.Core.csproj"
|
||||
},
|
||||
"C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.DependencyInjection\\e-suite.DependencyInjection.csproj": {
|
||||
"projectPath": "C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.DependencyInjection\\e-suite.DependencyInjection.csproj"
|
||||
},
|
||||
"C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.Utilities.Pagination\\e-suite.Utilities.Pagination\\e-suite.Utilities.Pagination.csproj": {
|
||||
"projectPath": "C:\\Users\\me\\OneDrive\\Code\\Gitea\\e-suite\\e-suite.Backend\\e-suite.Utilities.Pagination\\e-suite.Utilities.Pagination\\e-suite.Utilities.Pagination.csproj"
|
||||
}
|
||||
|
||||
@ -398,6 +398,7 @@
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
|
||||
"e-suite.Database.Core": "1.0.0",
|
||||
"e-suite.DependencyInjection": "1.0.0",
|
||||
"e-suite.Utilities.Pagination": "1.0.0"
|
||||
},
|
||||
"compile": {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.Database.Audit;
|
||||
using e_suite.Modules.UserManager.Facade;
|
||||
using e_suite.Modules.UserManager.Services;
|
||||
@ -12,7 +13,7 @@ using UserManager.UnitTests.Repository;
|
||||
|
||||
namespace UserManager.UnitTests.Helpers;
|
||||
|
||||
public class UserManagerTestBase : TestBase
|
||||
public class UserManagerTestBase<T> : TestBase
|
||||
{
|
||||
protected Mock<IPasswordHasher<IPassword>> CustomPasswordHasherMock = null!;
|
||||
protected Mock<ITwoFactorAuthenticator> TwoFactorAuthenticatorMock = null!;
|
||||
@ -21,6 +22,8 @@ public class UserManagerTestBase : TestBase
|
||||
protected Mock<IRandomNumberGenerator> RandomNumberGeneratorMock = null!;
|
||||
protected FakeUserManagerRepository UserManagerRepository = null!;
|
||||
protected FakeDomainRepository DomainRepository = null!;
|
||||
protected Mock<IPatchFactory> PatchFactoryMock = null!;
|
||||
protected Mock<IPatch<T>> PatchMock = null!;
|
||||
|
||||
protected AuditUserDetails AuditUserDetails = null!;
|
||||
protected SetupCode SetupCode = null!;
|
||||
@ -53,7 +56,13 @@ public class UserManagerTestBase : TestBase
|
||||
UserManagerRepository = new FakeUserManagerRepository(_fakeClock);
|
||||
DomainRepository = new FakeDomainRepository();
|
||||
|
||||
PatchFactoryMock = new Mock<IPatchFactory>();
|
||||
PatchMock = new Mock<IPatch<T>>();
|
||||
PatchFactoryMock
|
||||
.Setup(f => f.Create(It.IsAny<T>()))
|
||||
.Returns(PatchMock.Object);
|
||||
|
||||
UserManager = new e_suite.Modules.UserManager.UserManager(_configuration, CustomPasswordHasherMock.Object,
|
||||
TwoFactorAuthenticatorMock.Object, JwtServiceMock.Object, MailServiceMock.Object, RandomNumberGeneratorMock.Object, _fakeClock, UserManagerRepository, DomainRepository);
|
||||
TwoFactorAuthenticatorMock.Object, JwtServiceMock.Object, MailServiceMock.Object, RandomNumberGeneratorMock.Object, _fakeClock, UserManagerRepository, DomainRepository, PatchFactoryMock.Object);
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class CompleteEmailActionUnitTests : UserManagerTestBase
|
||||
public class CompleteEmailActionUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -7,7 +7,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class CreateSingleUseGuidUnitTests : UserManagerTestBase
|
||||
public class CreateSingleUseGuidUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -11,7 +11,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class CreateUserUnitTests : UserManagerTestBase
|
||||
public class CreateUserUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -8,7 +8,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class DeactivateUserUnitTests : UserManagerTestBase
|
||||
public class DeactivateUserUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
using System.Data;
|
||||
using e_suite.API.Common.exceptions;
|
||||
using e_suite.API.Common.exceptions;
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.Database.Core.Extensions;
|
||||
using e_suite.Database.Core.Tables.UserManager;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using NUnit.Framework;
|
||||
using System.Data;
|
||||
using UserManager.UnitTests.Helpers;
|
||||
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class EditUserUnitTests : UserManagerTestBase
|
||||
public class EditUserUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -9,7 +9,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class ForgotPasswordUnitTests : UserManagerTestBase
|
||||
public class ForgotPasswordUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -7,7 +7,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetCurrentEmailActionUrlUnitTests : UserManagerTestBase
|
||||
public class GetCurrentEmailActionUrlUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -8,7 +8,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetProfileUnitTests : UserManagerTestBase
|
||||
public class GetProfileUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -4,7 +4,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetSsoProviderByIdUnitTests : UserManagerTestBase
|
||||
public class GetSsoProviderByIdUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -6,7 +6,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetSsoProviderForEmailUnitTests : UserManagerTestBase
|
||||
public class GetSsoProviderForEmailUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -6,7 +6,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetUserAsyncUnitTests : UserManagerTestBase
|
||||
public class GetUserAsyncUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -5,7 +5,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetUserByEmailAsyncUnitTests : UserManagerTestBase
|
||||
public class GetUserByEmailAsyncUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -5,7 +5,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetUserWithSingleUseGuidUnitTests : UserManagerTestBase
|
||||
public class GetUserWithSingleUseGuidUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -5,7 +5,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class GetUsersAsyncUnitTests : UserManagerTestBase
|
||||
public class GetUsersAsyncUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -7,7 +7,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class LinkSsoProfileToUserUnitTests : UserManagerTestBase
|
||||
public class LinkSsoProfileToUserUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -6,7 +6,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class LoginSsoUnitTests : UserManagerTestBase
|
||||
public class LoginSsoUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -11,7 +11,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class LoginUnitTests : UserManagerTestBase
|
||||
public class LoginUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.Database.Core.Tables.UserManager;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using UserManager.UnitTests.Helpers;
|
||||
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class PatchProfileUnitTests : UserManagerTestBase<PatchUserProfile>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
{
|
||||
await base.Setup();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PatchProfile_AppliesPatchToUser()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new PatchUserProfile
|
||||
{
|
||||
FirstName = "Updated"
|
||||
};
|
||||
|
||||
const string existingEmail = "testuser@sun-strategy.com";
|
||||
var existingUser = new User
|
||||
{
|
||||
Id = 12,
|
||||
Email = existingEmail
|
||||
};
|
||||
await UserManagerRepository.AddUser(AuditUserDetails, existingUser, default);
|
||||
|
||||
PatchMock
|
||||
.Setup(p => p.ApplyTo(It.IsAny<User>()))
|
||||
.Callback<object>(target =>
|
||||
{
|
||||
((User)target).FirstName = "Updated";
|
||||
});
|
||||
|
||||
// Act
|
||||
await UserManager.PatchProfile(AuditUserDetails, existingUser.Email, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
PatchFactoryMock.Verify(f => f.Create(dto), Times.Once);
|
||||
PatchMock.Verify(p => p.ApplyTo(It.IsAny<User>()), Times.Once);
|
||||
|
||||
Assert.That(existingUser.FirstName, Is.EqualTo("Updated"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
using e_suite.API.Common;
|
||||
using e_suite.API.Common.models;
|
||||
using e_suite.Database.Core.Extensions;
|
||||
using e_suite.Database.Core.Tables.Domain;
|
||||
using e_suite.Database.Core.Tables.UserManager;
|
||||
using eSuite.Core.Miscellaneous;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using UserManager.UnitTests.Helpers;
|
||||
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class PatchUserUnitTests : UserManagerTestBase<PatchUser>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
{
|
||||
await base.Setup();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PatchUser_AppliesPatchToUser()
|
||||
{
|
||||
// Arrange
|
||||
var existingUser = new User
|
||||
{
|
||||
Id = 123,
|
||||
FirstName = "Original",
|
||||
LastName = "User",
|
||||
Active = true
|
||||
};
|
||||
|
||||
// Seed the fake repository
|
||||
await UserManagerRepository.AddUser(AuditUserDetails, existingUser, default);
|
||||
|
||||
var dto = new PatchUser
|
||||
{
|
||||
FirstName = "Updated"
|
||||
};
|
||||
|
||||
// Configure the patch mock to mutate the user
|
||||
PatchMock
|
||||
.Setup(p => p.ApplyTo(It.IsAny<User>()))
|
||||
.Callback<object>(target =>
|
||||
{
|
||||
var user = (User)target;
|
||||
user.FirstName = "Updated";
|
||||
});
|
||||
|
||||
// Act
|
||||
await UserManager.PatchUser(AuditUserDetails, existingUser.ToGeneralIdRef()!, dto, CancellationToken.None);
|
||||
|
||||
// Assert: factory was used
|
||||
PatchFactoryMock.Verify(f => f.Create(dto), Times.Once);
|
||||
|
||||
// Assert: patch was applied
|
||||
PatchMock.Verify(p => p.ApplyTo(It.IsAny<User>()), Times.Once);
|
||||
|
||||
// Assert: user was updated
|
||||
Assert.That(existingUser.FirstName, Is.EqualTo("Updated"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PatchUser_UpdatesDomain_WhenDomainRefProvided()
|
||||
{
|
||||
// Arrange
|
||||
var existingDomain = new Domain
|
||||
{
|
||||
Id = 10,
|
||||
Guid = Guid.NewGuid(),
|
||||
Name = "Original Domain"
|
||||
};
|
||||
|
||||
var newDomain = new Domain
|
||||
{
|
||||
Id = 20,
|
||||
Guid = Guid.NewGuid(),
|
||||
Name = "New Domain"
|
||||
};
|
||||
|
||||
// Seed both domains into the fake repository
|
||||
DomainRepository.Domains.Add(existingDomain);
|
||||
DomainRepository.Domains.Add(newDomain);
|
||||
|
||||
var existingUser = new User
|
||||
{
|
||||
Id = 123,
|
||||
FirstName = "Original",
|
||||
Active = true,
|
||||
DomainId = existingDomain.Id,
|
||||
Domain = existingDomain
|
||||
};
|
||||
UserManagerRepository.Users.Add(existingUser);
|
||||
|
||||
var dto = new PatchUser
|
||||
{
|
||||
Domain = new GeneralIdRef { Id = newDomain.Id }
|
||||
};
|
||||
|
||||
var userId = new GeneralIdRef { Id = 123 };
|
||||
|
||||
// Patch engine should not touch Domain (nested object)
|
||||
PatchMock
|
||||
.Setup(p => p.ApplyTo(It.IsAny<User>()))
|
||||
.Callback<object>(target =>
|
||||
{
|
||||
((User)target).DomainId = newDomain.Id;
|
||||
((User)target).Domain = newDomain;
|
||||
});
|
||||
|
||||
PatchFactoryMock
|
||||
.Setup(f => f.Create(dto))
|
||||
.Returns(PatchMock.Object);
|
||||
|
||||
// Act
|
||||
await UserManager.PatchUser(AuditUserDetails, userId, dto, CancellationToken.None);
|
||||
|
||||
// Assert: factory was used
|
||||
PatchFactoryMock.Verify(f => f.Create(dto), Times.Once);
|
||||
|
||||
// Assert: patch engine was invoked
|
||||
PatchMock.Verify(p => p.ApplyTo(It.IsAny<User>()), Times.Once);
|
||||
|
||||
// Assert: domain was updated
|
||||
Assert.That(existingUser.DomainId, Is.EqualTo(newDomain.Id));
|
||||
Assert.That(existingUser.Domain, Is.EqualTo(newDomain));
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class RefreshTokenByEmailUnitTests : UserManagerTestBase
|
||||
public class RefreshTokenByEmailUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -7,7 +7,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class RefreshTokenByIdUnitTests : UserManagerTestBase
|
||||
public class RefreshTokenByIdUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -8,7 +8,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class ResendConfirmEmailUnitTests : UserManagerTestBase
|
||||
public class ResendConfirmEmailUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -9,7 +9,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class SetAuthenticationUnitTests : UserManagerTestBase
|
||||
public class SetAuthenticationUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -7,7 +7,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class TurnOfSsoForUserUnitTests : UserManagerTestBase
|
||||
public class TurnOfSsoForUserUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -10,7 +10,7 @@ using UserManager.UnitTests.Helpers;
|
||||
namespace UserManager.UnitTests.UserManager;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateProfileUnitTests : UserManagerTestBase
|
||||
public class UpdateProfileUnitTests : UserManagerTestBase<object>
|
||||
{
|
||||
[SetUp]
|
||||
public override async Task Setup()
|
||||
|
||||
@ -5,4 +5,6 @@ public static class ESuiteClaimTypes
|
||||
public const string DomainId = "domainid";
|
||||
|
||||
public const string SecurityPrivileges = "securityPrivileges";
|
||||
|
||||
public const string PreferredLocale = "preferredLocale";
|
||||
}
|
||||
@ -55,7 +55,8 @@ public class JwtService : IJwtService
|
||||
new Claim(ClaimTypes.Name, user.DisplayName),
|
||||
new Claim(ESuiteClaimTypes.DomainId, user.Domain.Id.ToString()),
|
||||
//todo remove this from the token, and make it a separate call, so that I can keep the token small and keep the application running fast
|
||||
new Claim(ESuiteClaimTypes.SecurityPrivileges, GetSecurityPrivileges(user))
|
||||
new Claim(ESuiteClaimTypes.SecurityPrivileges, GetSecurityPrivileges(user)),
|
||||
new Claim(ESuiteClaimTypes.PreferredLocale, user.PreferredLocale)
|
||||
}),
|
||||
|
||||
Expires = DateTime.UtcNow.AddMinutes(double.Parse(_expDate)),
|
||||
|
||||
@ -21,6 +21,7 @@ using System.Text.Json;
|
||||
using e_suite.Modules.UserManager.Extensions;
|
||||
using IUserManager = e_suite.API.Common.IUserManager;
|
||||
using System.Data;
|
||||
using e_suite.API.Common;
|
||||
|
||||
namespace e_suite.Modules.UserManager;
|
||||
|
||||
@ -33,11 +34,13 @@ public partial class UserManager : IUserManager
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IRandomNumberGenerator _randomNumberGenerator;
|
||||
private readonly IClock _clock;
|
||||
private readonly IPatchFactory _patchFactory;
|
||||
|
||||
private readonly IUserManagerRepository _userManagerRepository;
|
||||
private readonly IDomainRepository _domainRepository;
|
||||
|
||||
public UserManager(IConfiguration configuration, IPasswordHasher<IPassword> passwordHasher, ITwoFactorAuthenticator twoFactorAuthenticator, IJwtService jwtService, IMailService mailService, IRandomNumberGenerator randomNumberGenerator, IClock clock, IUserManagerRepository userManagerRepository, IDomainRepository domainRepository)
|
||||
|
||||
public UserManager(IConfiguration configuration, IPasswordHasher<IPassword> passwordHasher, ITwoFactorAuthenticator twoFactorAuthenticator, IJwtService jwtService, IMailService mailService, IRandomNumberGenerator randomNumberGenerator, IClock clock, IUserManagerRepository userManagerRepository, IDomainRepository domainRepository, IPatchFactory patchFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_passwordHasher = passwordHasher;
|
||||
@ -48,6 +51,7 @@ public partial class UserManager : IUserManager
|
||||
_clock = clock;
|
||||
_userManagerRepository = userManagerRepository;
|
||||
_domainRepository = domainRepository;
|
||||
_patchFactory = patchFactory;
|
||||
}
|
||||
|
||||
private void HashPassword(User user, string password)
|
||||
@ -468,7 +472,8 @@ public partial class UserManager : IUserManager
|
||||
SsoProviderId = user.SsoProviderId,
|
||||
SsoSubject = user.SsoSubject,
|
||||
SsoProviders = ssoProviders
|
||||
.ToDictionary(x => x.Id, x => x.Name)
|
||||
.ToDictionary(x => x.Id, x => x.Name),
|
||||
PreferredLocale = user.PreferredLocale
|
||||
};
|
||||
|
||||
return userProfile;
|
||||
@ -483,9 +488,8 @@ public partial class UserManager : IUserManager
|
||||
|
||||
public async Task UpdateProfile(AuditUserDetails auditUserDetails, string email, UpdatedUserProfile userProfile, CancellationToken cancellationToken)
|
||||
{
|
||||
await _userManagerRepository.TransactionAsync(async () =>
|
||||
await AlterProfile(auditUserDetails, email, async user =>
|
||||
{
|
||||
var user = await GetUserByEmailAsync(email, cancellationToken);
|
||||
userProfile.Email = userProfile.Email.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userProfile.Email))
|
||||
@ -504,18 +508,52 @@ public partial class UserManager : IUserManager
|
||||
user.MiddleNames = userProfile.MiddleNames;
|
||||
user.LastName = userProfile.LastName;
|
||||
user.Email = userProfile.Email;
|
||||
user.PreferredLocale = userProfile.PreferredLocale;
|
||||
|
||||
await SetInternalAuthenticationDetails(user, userProfile, true, cancellationToken);
|
||||
},
|
||||
cancellationToken );
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> PatchProfile( AuditUserDetails auditUserDetails, string email, PatchUserProfile patchUserProfile, CancellationToken cancellationToken)
|
||||
{
|
||||
var patch = _patchFactory.Create(patchUserProfile);
|
||||
|
||||
var user = await AlterProfile(auditUserDetails, email, async user =>
|
||||
{
|
||||
await patch.ApplyTo(user);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return CreateLoginResponse(user);
|
||||
}
|
||||
|
||||
private async Task<User> AlterProfile(
|
||||
AuditUserDetails auditUserDetails,
|
||||
string email,
|
||||
Func<User, Task> applyChanges,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
User user = null!;
|
||||
await _userManagerRepository.TransactionAsync(async () =>
|
||||
{
|
||||
user = await GetUserByEmailAsync(email, cancellationToken);
|
||||
|
||||
await applyChanges(user);
|
||||
|
||||
await _userManagerRepository.EditUser(auditUserDetails, user, cancellationToken);
|
||||
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
await SendEmailUserAction(auditUserDetails, user, EmailUserActionType.ConfirmEmailAddress, cancellationToken);
|
||||
}
|
||||
});
|
||||
await SendEmailUserAction(auditUserDetails, user, EmailUserActionType.ConfirmEmailAddress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
private async Task SetInternalAuthenticationDetails(
|
||||
User user,
|
||||
UpdatedUserProfile userProfile,
|
||||
@ -546,16 +584,13 @@ public partial class UserManager : IUserManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EditUser(AuditUserDetails auditUserDetails, EditUser user, CancellationToken cancellationToken)
|
||||
public async Task EditUser(
|
||||
AuditUserDetails auditUserDetails,
|
||||
EditUser user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _userManagerRepository.TransactionAsync(async () =>
|
||||
await AlterUser(auditUserDetails, user.Id, async editUser =>
|
||||
{
|
||||
var editUser = await _userManagerRepository.GetUserById(user.Id! , cancellationToken)
|
||||
?? throw new NotFoundException("unable to find user");
|
||||
|
||||
if (!editUser.Active)
|
||||
throw new DeletedRowInaccessibleException("This user is inactive so cannot be modified. You will need to create the user again.");
|
||||
|
||||
var userDomain = await _userManagerRepository.GetDomainById(user.Domain, cancellationToken)
|
||||
?? throw new NotFoundException("unable to find domain");
|
||||
|
||||
@ -566,10 +601,45 @@ public partial class UserManager : IUserManager
|
||||
editUser.DomainId = userDomain.Id;
|
||||
editUser.Domain = userDomain;
|
||||
|
||||
await _userManagerRepository.EditUser(auditUserDetails, editUser, cancellationToken);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PatchUser(
|
||||
AuditUserDetails auditUserDetails,
|
||||
IGeneralIdRef userId,
|
||||
PatchUser patchUser,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var patch = _patchFactory.Create(patchUser);
|
||||
|
||||
await AlterUser(auditUserDetails, userId, async editUser =>
|
||||
{
|
||||
await patch.ApplyTo(editUser);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AlterUser(
|
||||
AuditUserDetails auditUserDetails,
|
||||
IGeneralIdRef userId,
|
||||
Func<User, Task> applyChanges,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _userManagerRepository.TransactionAsync(async () =>
|
||||
{
|
||||
var user = await _userManagerRepository.GetUserById(userId, cancellationToken)
|
||||
?? throw new NotFoundException("unable to find user");
|
||||
|
||||
if (!user.Active)
|
||||
throw new DeletedRowInaccessibleException("This user is inactive so cannot be modified. You will need to create the user again.");
|
||||
|
||||
await applyChanges(user);
|
||||
|
||||
await _userManagerRepository.EditUser(auditUserDetails, user, cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public async Task<IPaginatedData<GetUser>> GetUsersAsync(Paging paging, CancellationToken cancellationToken)
|
||||
{
|
||||
var users = _userManagerRepository.GetUsers().Where(x => x.Active == true);
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using e_suite.Database.Core;
|
||||
using e_suite.Database.Audit;
|
||||
using e_suite.Database.Audit.AuditEngine;
|
||||
using e_suite.Database.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
|
||||
@ -29,4 +31,9 @@ public class FakeRepository : IRepository
|
||||
|
||||
return dbSet.Object;
|
||||
}
|
||||
|
||||
public Task<int> AddAdhocAuditEntry(AuditUserDetails auditUserDetails, AuditType auditType, Dictionary<string, Change> fields, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@ -9,10 +9,10 @@
|
||||
</TestAncestor>
|
||||
</SessionState></s:String>
|
||||
<s:Boolean x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/@KeyIndexDefined">True</s:Boolean>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Coords/@EntryValue">(Doc Ln 69 Col 34)</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/FileId/@EntryValue">ABA889AD-4D7F-45BF-8968-8BD388F33849/f:PerformanceManager.cs</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Coords/@EntryValue">(Doc Ln 120 Col 0)</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/FileId/@EntryValue">7DC1F493-76A5-3740-E774-C8DAA51ED83A/f:PatchUnitTests.cs</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Owner/@EntryValue">NumberedBookmarkManager</s:String>
|
||||
<s:Boolean x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/@KeyIndexDefined">True</s:Boolean>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/Coords/@EntryValue">(Doc Ln 40 Col 59)</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/FileId/@EntryValue">8A9B7274-A7D4-4270-A374-285B2B1BB7CD/d:Repository/f:CustomFieldRepository.cs</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/Coords/@EntryValue">(Doc Ln 606 Col 8)</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/FileId/@EntryValue">A00B7AED-96DF-49A5-BA8F-9BE74021F3CF/f:UserManager.cs</s:String>
|
||||
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/Owner/@EntryValue">NumberedBookmarkManager</s:String></wpf:ResourceDictionary>
|
||||
Loading…
Reference in New Issue
Block a user