diff --git a/Tharga.Platform.sln b/Tharga.Platform.sln index fd4f33d..1851820 100644 --- a/Tharga.Platform.sln +++ b/Tharga.Platform.sln @@ -30,6 +30,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Platform.Mcp", "Thar EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Platform.Mcp.Tests", "Tharga.Platform.Mcp.Tests\Tharga.Platform.Mcp.Tests.csproj", "{32F3F58A-486D-44FB-AAD1-DF6CB19100B0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Team.MongoDB.Tests", "Tharga.Team.MongoDB.Tests\Tharga.Team.MongoDB.Tests.csproj", "{F53219A8-CB6D-423C-9314-1397F08E1202}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -148,6 +150,18 @@ Global {32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x64.Build.0 = Release|Any CPU {32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x86.ActiveCfg = Release|Any CPU {32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x86.Build.0 = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x64.ActiveCfg = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x64.Build.0 = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x86.ActiveCfg = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x86.Build.0 = Debug|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|Any CPU.Build.0 = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x64.ActiveCfg = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x64.Build.0 = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x86.ActiveCfg = Release|Any CPU + {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs b/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs new file mode 100644 index 0000000..f397c18 --- /dev/null +++ b/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tharga.MongoDB; + +namespace Tharga.Team.MongoDB.Tests; + +/// +/// Verifies the consumer-facing index extension point added under Tharga/Platform#65: +/// registers a +/// subclass of as the implementation of +/// , so consumers can declare per-deployment indices. +/// +/// We inspect the on the registered +/// rather than building the provider, because the underlying DiskRepositoryCollectionBase +/// ctor casts the injected factory to a concrete type that a test substitute can't satisfy. +/// +public class RegisterUserRepositoryTests +{ + [Fact] + public void RegisterUserRepository_Default_Overload_Registers_Builtin_Collection() + { + var services = new ServiceCollection(); + services.AddThargaTeamRepository(o => o.RegisterUserRepository()); + + var descriptor = services.Single(s => + s.ServiceType == typeof(IUserRepositoryCollection)); + + Assert.Equal( + typeof(UserRepositoryCollection), + descriptor.ImplementationType); + } + + [Fact] + public void RegisterUserRepository_Subclass_Overload_Registers_Consumer_Subclass() + { + var services = new ServiceCollection(); + services.AddThargaTeamRepository(o => + o.RegisterUserRepository()); + + var descriptor = services.Single(s => + s.ServiceType == typeof(IUserRepositoryCollection)); + + Assert.Equal(typeof(CustomCollection), descriptor.ImplementationType); + } + + public class CustomCollection : UserRepositoryCollection + { + public CustomCollection(IMongoDbServiceFactory mongoDbServiceFactory, ILogger> logger, IOptions options = null) + : base(mongoDbServiceFactory, logger, options) { } + } +} diff --git a/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj b/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj new file mode 100644 index 0000000..f7f0794 --- /dev/null +++ b/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + false + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs b/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs new file mode 100644 index 0000000..4da4bbf --- /dev/null +++ b/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs @@ -0,0 +1,37 @@ +using System.Runtime.CompilerServices; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; + +namespace Tharga.Team.MongoDB.Tests; + +/// +/// Verifies that declares the +/// unique Identity index added under Tharga/Platform#65. +/// +/// The base DiskRepositoryCollectionBase ctor casts the injected factory to a concrete +/// MongoDbService, which an NSubstitute proxy can't satisfy. Since the override is purely +/// declarative and doesn't depend on instance state, we bypass the ctor via +/// . +/// +public class UserRepositoryCollectionIndicesTests +{ + [Fact] + public void Indices_Includes_Unique_Identity_Index() + { + var collection = (UserRepositoryCollection) + RuntimeHelpers.GetUninitializedObject(typeof(UserRepositoryCollection)); + + var indices = collection.Indices.ToArray(); + + var identityIndex = Assert.Single(indices); + Assert.Equal("Identity", identityIndex.Options.Name); + Assert.True(identityIndex.Options.Unique); + + var keyDoc = identityIndex.Keys.Render(new RenderArgs( + BsonSerializer.SerializerRegistry.GetSerializer(), + BsonSerializer.SerializerRegistry)); + Assert.True(keyDoc.Contains("Identity")); + Assert.Equal(1, keyDoc["Identity"].AsInt32); + } +} diff --git a/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs b/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs new file mode 100644 index 0000000..2c974a5 --- /dev/null +++ b/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs @@ -0,0 +1,139 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using MongoDB.Bson; +using MongoDB.Driver; +using Tharga.MongoDB; + +namespace Tharga.Team.MongoDB.Tests; + +/// +/// Verifies the catch-DuplicateKey recovery path in . +/// When two first-time logins for the same Identity race, the unique Identity index on +/// guarantees one wins. The losing thread catches +/// the with and re-reads +/// the winning row instead of throwing (issue Tharga/Platform#65). +/// +public class UserServiceRepositoryBaseRaceTests +{ + [Fact] + public async Task GetUserAsync_NoExistingUser_AddsAndReturnsCandidate() + { + var repo = Substitute.For>(); + repo.GetAsync("alice@example.com").Returns((TestUserEntity)null); + repo.AddAsync(Arg.Any()).Returns(Task.CompletedTask); + + var sut = new TestUserService(repo, new TestUserEntity { Identity = "alice@example.com", Key = "u-alice" }); + var result = await sut.InvokeGetUserAsync(BuildClaims("alice@example.com")); + + Assert.NotNull(result); + Assert.Equal("u-alice", result.Key); + await repo.Received(1).AddAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserAsync_ExistingUser_DoesNotAdd() + { + var existing = new TestUserEntity { Identity = "bob@example.com", Key = "u-bob" }; + var repo = Substitute.For>(); + repo.GetAsync("bob@example.com").Returns(existing); + + var sut = new TestUserService(repo, new TestUserEntity { Identity = "bob@example.com", Key = "should-not-be-used" }); + var result = await sut.InvokeGetUserAsync(BuildClaims("bob@example.com")); + + Assert.Same(existing, result); + await repo.DidNotReceive().AddAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserAsync_AddThrowsDuplicateKey_ReReadsAndReturnsWinner() + { + // Race: first GetAsync returns null, candidate is created, AddAsync hits the unique-Identity + // index conflict and throws. Recovery re-reads by Identity and returns the winner. + var winner = new TestUserEntity { Identity = "carol@example.com", Key = "u-winner" }; + var repo = Substitute.For>(); + repo.GetAsync("carol@example.com").Returns((TestUserEntity)null, winner); + repo.AddAsync(Arg.Any()).Returns(_ => throw BuildDuplicateKeyException()); + + var sut = new TestUserService(repo, new TestUserEntity { Identity = "carol@example.com", Key = "u-loser" }); + var result = await sut.InvokeGetUserAsync(BuildClaims("carol@example.com")); + + Assert.Same(winner, result); + await repo.Received(2).GetAsync("carol@example.com"); + await repo.Received(1).AddAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserAsync_AddThrowsNonDuplicateKey_Propagates() + { + var repo = Substitute.For>(); + repo.GetAsync("dave@example.com").Returns((TestUserEntity)null); + repo.AddAsync(Arg.Any()).Returns(_ => throw new InvalidOperationException("unrelated")); + + var sut = new TestUserService(repo, new TestUserEntity { Identity = "dave@example.com", Key = "u-dave" }); + + await Assert.ThrowsAsync(() => sut.InvokeGetUserAsync(BuildClaims("dave@example.com"))); + } + + private static ClaimsPrincipal BuildClaims(string identity) + { + // Tharga.Toolkit's GetIdentity().Identity reads ClaimTypes.NameIdentifier off the principal. + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, identity) }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + /// + /// Construct a carrying a with + /// via reflection. Both types have only + /// internal constructors in MongoDB.Driver 3.x; we bypass them via + /// and set backing fields directly. + /// Fragile across driver upgrades but sufficient for a single test fixture. + /// + private static MongoWriteException BuildDuplicateKeyException() + { + var writeError = (WriteError)RuntimeHelpers.GetUninitializedObject(typeof(WriteError)); + SetField(writeError, "_category", ServerErrorCategory.DuplicateKey); + SetField(writeError, "_code", 11000); + SetField(writeError, "_message", "E11000 duplicate key error"); + SetField(writeError, "_details", new BsonDocument()); + + var exception = (MongoWriteException)RuntimeHelpers.GetUninitializedObject(typeof(MongoWriteException)); + SetField(exception, "_writeError", writeError); + return exception; + } + + private static void SetField(object instance, string fieldName, object value) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + if (field == null) throw new InvalidOperationException($"Field '{fieldName}' not found on {instance.GetType().FullName}. The MongoDB.Driver internal layout may have changed."); + field.SetValue(instance, value); + } + + public record TestUserEntity : EntityBase, IUser + { + public string Identity { get; init; } + public string Key { get; init; } + public string EMail { get; init; } + public string Name { get; init; } + } + + private sealed class TestUserService : UserServiceRepositoryBase + { + private readonly TestUserEntity _candidate; + + public TestUserService(IUserRepository repo, TestUserEntity candidate) + : base(Substitute.For(), repo) + { + _candidate = candidate; + } + + protected override Task CreateUserEntityAsync(ClaimsPrincipal claimsPrincipal, string identity) + { + return Task.FromResult(_candidate); + } + + // Expose the protected method for direct invocation in tests. + public Task InvokeGetUserAsync(ClaimsPrincipal principal) => GetUserAsync(principal); + } +} diff --git a/Tharga.Team.MongoDB/README.md b/Tharga.Team.MongoDB/README.md index f7716c5..61d591a 100644 --- a/Tharga.Team.MongoDB/README.md +++ b/Tharga.Team.MongoDB/README.md @@ -26,6 +26,32 @@ builder.Services.AddThargaTeamRepository(o => Requires [Tharga.MongoDB](https://www.nuget.org/packages/Tharga.MongoDB) to be configured with a connection string. +## Adding per-deployment User indices + +The built-in `UserRepositoryCollection` declares a unique index on `Identity`. To add additional indices (e.g. on a custom email column), subclass the collection and register the subclass via the `RegisterUserRepository` overload: + +```csharp +public class MyUserRepositoryCollection : UserRepositoryCollection +{ + public MyUserRepositoryCollection(IMongoDbServiceFactory factory, ILogger> logger, IOptions options = null) + : base(factory, logger, options) { } + + public override IEnumerable> Indices => + [ + // Keep the base Identity index + ..base.Indices, + // Plus your own + new(Builders.IndexKeys.Ascending(x => x.EMail), + new CreateIndexOptions { Unique = true, Name = "EMail" }) + ]; +} + +builder.Services.AddThargaTeamRepository(o => +{ + o.RegisterUserRepository(); +}); +``` + ## Dependencies - [Tharga.Team](https://www.nuget.org/packages/Tharga.Team) - Domain models and service abstractions. diff --git a/Tharga.Team.MongoDB/ThargaTeamOptions.cs b/Tharga.Team.MongoDB/ThargaTeamOptions.cs index 2a430be..ba6cb21 100644 --- a/Tharga.Team.MongoDB/ThargaTeamOptions.cs +++ b/Tharga.Team.MongoDB/ThargaTeamOptions.cs @@ -5,6 +5,7 @@ namespace Tharga.Team.MongoDB; public record ThargaTeamOptions { internal Type _userEntity; + internal Type _userCollectionType; internal Type _teamEntity; internal Type _teamMemberModel; @@ -18,10 +19,29 @@ public record ThargaTeamOptions /// public string UserCollectionName { get; set; } = "User"; + /// + /// Registers the User repository using the built-in . + /// Use the RegisterUserRepository<TUserEntity, TCollection> overload to register a consumer + /// subclass that declares additional per-deployment indices. + /// public void RegisterUserRepository() where TUserEntity : EntityBase, IUser { _userEntity = typeof(TUserEntity); + _userCollectionType = null; + } + + /// + /// Registers the User repository with a consumer-provided collection subclass. + /// Use this when you need to add per-deployment indices on top of the built-in + /// unique Identity index (e.g. a unique index on a custom email field). + /// + public void RegisterUserRepository() + where TUserEntity : EntityBase, IUser + where TCollection : UserRepositoryCollection + { + _userEntity = typeof(TUserEntity); + _userCollectionType = typeof(TCollection); } public void RegisterTeamRepository() diff --git a/Tharga.Team.MongoDB/ThargaTeamRegistration.cs b/Tharga.Team.MongoDB/ThargaTeamRegistration.cs index 019ace1..9d74888 100644 --- a/Tharga.Team.MongoDB/ThargaTeamRegistration.cs +++ b/Tharga.Team.MongoDB/ThargaTeamRegistration.cs @@ -21,7 +21,8 @@ public static void AddThargaTeamRepository(this IServiceCollection services, Act var userRepositoryImplementationType = typeof(UserRepository<>).MakeGenericType(userEntityType); var userRepositoryCollectionInterfaceType = typeof(IUserRepositoryCollection<>).MakeGenericType(userEntityType); - var userRepositoryCollectionImplementationType = typeof(UserRepositoryCollection<>).MakeGenericType(userEntityType); + var userRepositoryCollectionImplementationType = o._userCollectionType + ?? typeof(UserRepositoryCollection<>).MakeGenericType(userEntityType); services.AddTransient(userRepositoryInterfaceType, userRepositoryImplementationType); services.AddTransient(userRepositoryCollectionInterfaceType, userRepositoryCollectionImplementationType); diff --git a/Tharga.Team.MongoDB/UserRepositoryCollection.cs b/Tharga.Team.MongoDB/UserRepositoryCollection.cs index e8bed6d..68a7c8f 100644 --- a/Tharga.Team.MongoDB/UserRepositoryCollection.cs +++ b/Tharga.Team.MongoDB/UserRepositoryCollection.cs @@ -1,11 +1,18 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; using Tharga.MongoDB; using Tharga.MongoDB.Disk; namespace Tharga.Team.MongoDB; -internal class UserRepositoryCollection : DiskRepositoryCollectionBase, IUserRepositoryCollection +/// +/// MongoDB collection definition for User documents. Public so consumers can subclass it +/// to declare per-deployment indices on their own shape. +/// Register the subclass via . +/// +public class UserRepositoryCollection : DiskRepositoryCollectionBase, IUserRepositoryCollection where TUserEntity : EntityBase, IUser { private readonly string _collectionName; @@ -17,4 +24,10 @@ public UserRepositoryCollection(IMongoDbServiceFactory mongoDbServiceFactory, IL } public override string CollectionName => _collectionName; + + public override IEnumerable> Indices => + [ + new(Builders.IndexKeys.Ascending(x => x.Identity), + new CreateIndexOptions { Unique = true, Name = "Identity" }) + ]; } diff --git a/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs b/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs index e11aea9..5cd3021 100644 --- a/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs +++ b/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components.Authorization; +using MongoDB.Driver; using System.Security.Claims; using Tharga.MongoDB; using Tharga.Toolkit; @@ -23,13 +24,21 @@ protected override async Task GetUserAsync(ClaimsPrincipal claimsPrincipa var identity = claimsPrincipal.GetIdentity().Identity; var user = await _userRepository.GetAsync(identity); - if (user == null) + if (user != null) return user; + + var candidate = await CreateUserEntityAsync(claimsPrincipal, identity); + try { - user = await CreateUserEntityAsync(claimsPrincipal, identity); - await _userRepository.AddAsync(user); + await _userRepository.AddAsync(candidate); + return candidate; + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Lost the race against a concurrent first-time login for the same identity. + // The unique Identity index on UserRepositoryCollection guarantees only one wins; + // re-read and return the winner. Issue Tharga/Platform#65. + return await _userRepository.GetAsync(identity); } - - return user; } protected override IAsyncEnumerable GetAllAsync()