Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Tharga.Platform.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tharga.MongoDB;

namespace Tharga.Team.MongoDB.Tests;

/// <summary>
/// Verifies the consumer-facing index extension point added under Tharga/Platform#65:
/// <see cref="ThargaTeamOptions.RegisterUserRepository{TUserEntity, TCollection}"/> registers a
/// subclass of <see cref="UserRepositoryCollection{TUserEntity}"/> as the implementation of
/// <see cref="IUserRepositoryCollection{TUserEntity}"/>, so consumers can declare per-deployment indices.
///
/// We inspect the <see cref="ServiceDescriptor"/> on the registered <see cref="IServiceCollection"/>
/// rather than building the provider, because the underlying <c>DiskRepositoryCollectionBase</c>
/// ctor casts the injected factory to a concrete type that a test substitute can't satisfy.
/// </summary>
public class RegisterUserRepositoryTests
{
[Fact]
public void RegisterUserRepository_Default_Overload_Registers_Builtin_Collection()
{
var services = new ServiceCollection();
services.AddThargaTeamRepository(o => o.RegisterUserRepository<UserServiceRepositoryBaseRaceTests.TestUserEntity>());

var descriptor = services.Single(s =>
s.ServiceType == typeof(IUserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>));

Assert.Equal(
typeof(UserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>),
descriptor.ImplementationType);
}

[Fact]
public void RegisterUserRepository_Subclass_Overload_Registers_Consumer_Subclass()
{
var services = new ServiceCollection();
services.AddThargaTeamRepository(o =>
o.RegisterUserRepository<UserServiceRepositoryBaseRaceTests.TestUserEntity, CustomCollection>());

var descriptor = services.Single(s =>
s.ServiceType == typeof(IUserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>));

Assert.Equal(typeof(CustomCollection), descriptor.ImplementationType);
}

public class CustomCollection : UserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>
{
public CustomCollection(IMongoDbServiceFactory mongoDbServiceFactory, ILogger<UserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>> logger, IOptions<ThargaTeamOptions> options = null)
: base(mongoDbServiceFactory, logger, options) { }
}
}
27 changes: 27 additions & 0 deletions Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tharga.Team.MongoDB\Tharga.Team.MongoDB.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="NSubstitute" />
</ItemGroup>
</Project>
37 changes: 37 additions & 0 deletions Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Runtime.CompilerServices;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;

namespace Tharga.Team.MongoDB.Tests;

/// <summary>
/// Verifies that <see cref="UserRepositoryCollection{TUserEntity}.Indices"/> declares the
/// unique <c>Identity</c> index added under Tharga/Platform#65.
///
/// The base <c>DiskRepositoryCollectionBase</c> ctor casts the injected factory to a concrete
/// <c>MongoDbService</c>, 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
/// <see cref="RuntimeHelpers.GetUninitializedObject"/>.
/// </summary>
public class UserRepositoryCollectionIndicesTests
{
[Fact]
public void Indices_Includes_Unique_Identity_Index()
{
var collection = (UserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>)
RuntimeHelpers.GetUninitializedObject(typeof(UserRepositoryCollection<UserServiceRepositoryBaseRaceTests.TestUserEntity>));

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<UserServiceRepositoryBaseRaceTests.TestUserEntity>(
BsonSerializer.SerializerRegistry.GetSerializer<UserServiceRepositoryBaseRaceTests.TestUserEntity>(),
BsonSerializer.SerializerRegistry));
Assert.True(keyDoc.Contains("Identity"));
Assert.Equal(1, keyDoc["Identity"].AsInt32);
}
}
139 changes: 139 additions & 0 deletions Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies the catch-DuplicateKey recovery path in <see cref="UserServiceRepositoryBase{TUserEntity}.GetUserAsync"/>.
/// When two first-time logins for the same Identity race, the unique <c>Identity</c> index on
/// <see cref="UserRepositoryCollection{TUserEntity}"/> guarantees one wins. The losing thread catches
/// the <see cref="MongoWriteException"/> with <see cref="ServerErrorCategory.DuplicateKey"/> and re-reads
/// the winning row instead of throwing (issue Tharga/Platform#65).
/// </summary>
public class UserServiceRepositoryBaseRaceTests
{
[Fact]
public async Task GetUserAsync_NoExistingUser_AddsAndReturnsCandidate()
{
var repo = Substitute.For<IUserRepository<TestUserEntity>>();
repo.GetAsync("alice@example.com").Returns((TestUserEntity)null);
repo.AddAsync(Arg.Any<TestUserEntity>()).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<TestUserEntity>());
}

[Fact]
public async Task GetUserAsync_ExistingUser_DoesNotAdd()
{
var existing = new TestUserEntity { Identity = "bob@example.com", Key = "u-bob" };
var repo = Substitute.For<IUserRepository<TestUserEntity>>();
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<TestUserEntity>());
}

[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<IUserRepository<TestUserEntity>>();
repo.GetAsync("carol@example.com").Returns((TestUserEntity)null, winner);
repo.AddAsync(Arg.Any<TestUserEntity>()).Returns<Task>(_ => 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<TestUserEntity>());
}

[Fact]
public async Task GetUserAsync_AddThrowsNonDuplicateKey_Propagates()
{
var repo = Substitute.For<IUserRepository<TestUserEntity>>();
repo.GetAsync("dave@example.com").Returns((TestUserEntity)null);
repo.AddAsync(Arg.Any<TestUserEntity>()).Returns<Task>(_ => throw new InvalidOperationException("unrelated"));

var sut = new TestUserService(repo, new TestUserEntity { Identity = "dave@example.com", Key = "u-dave" });

await Assert.ThrowsAsync<InvalidOperationException>(() => 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"));
}

/// <summary>
/// Construct a <see cref="MongoWriteException"/> carrying a <see cref="WriteError"/> with
/// <see cref="ServerErrorCategory.DuplicateKey"/> via reflection. Both types have only
/// <c>internal</c> constructors in MongoDB.Driver 3.x; we bypass them via
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> and set backing fields directly.
/// Fragile across driver upgrades but sufficient for a single test fixture.
/// </summary>
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<TestUserEntity>
{
private readonly TestUserEntity _candidate;

public TestUserService(IUserRepository<TestUserEntity> repo, TestUserEntity candidate)
: base(Substitute.For<AuthenticationStateProvider>(), repo)
{
_candidate = candidate;
}

protected override Task<TestUserEntity> CreateUserEntityAsync(ClaimsPrincipal claimsPrincipal, string identity)
{
return Task.FromResult(_candidate);
}

// Expose the protected method for direct invocation in tests.
public Task<IUser> InvokeGetUserAsync(ClaimsPrincipal principal) => GetUserAsync(principal);
}
}
26 changes: 26 additions & 0 deletions Tharga.Team.MongoDB/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TUserEntity>` 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<TUserEntity, TCollection>` overload:

```csharp
public class MyUserRepositoryCollection : UserRepositoryCollection<MyUserEntity>
{
public MyUserRepositoryCollection(IMongoDbServiceFactory factory, ILogger<UserRepositoryCollection<MyUserEntity>> logger, IOptions<ThargaTeamOptions> options = null)
: base(factory, logger, options) { }

public override IEnumerable<CreateIndexModel<MyUserEntity>> Indices =>
[
// Keep the base Identity index
..base.Indices,
// Plus your own
new(Builders<MyUserEntity>.IndexKeys.Ascending(x => x.EMail),
new CreateIndexOptions { Unique = true, Name = "EMail" })
];
}

builder.Services.AddThargaTeamRepository(o =>
{
o.RegisterUserRepository<MyUserEntity, MyUserRepositoryCollection>();
});
```

## Dependencies

- [Tharga.Team](https://www.nuget.org/packages/Tharga.Team) - Domain models and service abstractions.
Expand Down
20 changes: 20 additions & 0 deletions Tharga.Team.MongoDB/ThargaTeamOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Tharga.Team.MongoDB;
public record ThargaTeamOptions
{
internal Type _userEntity;
internal Type _userCollectionType;
internal Type _teamEntity;
internal Type _teamMemberModel;

Expand All @@ -18,10 +19,29 @@ public record ThargaTeamOptions
/// </summary>
public string UserCollectionName { get; set; } = "User";

/// <summary>
/// Registers the User repository using the built-in <see cref="UserRepositoryCollection{TUserEntity}"/>.
/// Use the <c>RegisterUserRepository&lt;TUserEntity, TCollection&gt;</c> overload to register a consumer
/// subclass that declares additional per-deployment indices.
/// </summary>
public void RegisterUserRepository<TUserEntity>()
where TUserEntity : EntityBase, IUser
{
_userEntity = typeof(TUserEntity);
_userCollectionType = null;
}

/// <summary>
/// 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 <c>Identity</c> index (e.g. a unique index on a custom email field).
/// </summary>
public void RegisterUserRepository<TUserEntity, TCollection>()
where TUserEntity : EntityBase, IUser
where TCollection : UserRepositoryCollection<TUserEntity>
{
_userEntity = typeof(TUserEntity);
_userCollectionType = typeof(TCollection);
}

public void RegisterTeamRepository<TTeamEntity, TTeamMemberModel>()
Expand Down
3 changes: 2 additions & 1 deletion Tharga.Team.MongoDB/ThargaTeamRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion Tharga.Team.MongoDB/UserRepositoryCollection.cs
Original file line number Diff line number Diff line change
@@ -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<TUserEntity> : DiskRepositoryCollectionBase<TUserEntity>, IUserRepositoryCollection<TUserEntity>
/// <summary>
/// MongoDB collection definition for User documents. Public so consumers can subclass it
/// to declare per-deployment indices on their own <typeparamref name="TUserEntity"/> shape.
/// Register the subclass via <see cref="ThargaTeamOptions.RegisterUserRepository{TUserEntity, TCollection}"/>.
/// </summary>
public class UserRepositoryCollection<TUserEntity> : DiskRepositoryCollectionBase<TUserEntity>, IUserRepositoryCollection<TUserEntity>
where TUserEntity : EntityBase, IUser
{
private readonly string _collectionName;
Expand All @@ -17,4 +24,10 @@ public UserRepositoryCollection(IMongoDbServiceFactory mongoDbServiceFactory, IL
}

public override string CollectionName => _collectionName;

public override IEnumerable<CreateIndexModel<TUserEntity>> Indices =>
[
new(Builders<TUserEntity>.IndexKeys.Ascending(x => x.Identity),
new CreateIndexOptions { Unique = true, Name = "Identity" })
];
}
Loading
Loading