Added a fleshed out patch mechanism and implemented two Rest Patch methods.

This commit is contained in:
Colin Dawson 2026-01-28 23:47:32 +00:00
parent ef42434d60
commit 90b23f939c
62 changed files with 3706 additions and 67 deletions

View File

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

View File

@ -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 nonpublic 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}'.");
}
}
}
}

View File

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

View File

@ -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>

View File

@ -0,0 +1,7 @@
namespace e_suite.API.Common;
public interface IPatch<T>
{
T Value { get; }
Task ApplyTo(object target);
}

View File

@ -0,0 +1,8 @@
using e_suite.Database.Core;
namespace e_suite.API.Common;
public interface IPatchFactory
{
IPatch<T> Create<T>(T value);
}

View File

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

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

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

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

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

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

View File

@ -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>

View File

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

View File

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

View File

@ -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;

View File

@ -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)

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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"
}

View File

@ -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": {

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

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

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -5,4 +5,6 @@ public static class ESuiteClaimTypes
public const string DomainId = "domainid";
public const string SecurityPrivileges = "securityPrivileges";
public const string PreferredLocale = "preferredLocale";
}

View File

@ -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)),

View File

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

View File

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

View File

@ -9,10 +9,10 @@
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</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>