Initial commit

This commit is contained in:
BillyCool
2026-04-21 01:10:25 +10:00
commit c5595ea083
1752 changed files with 45767 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
using Grpc.Core;
namespace MariesWonderland.Tests.Infrastructure;
/// <summary>
/// Minimal <see cref="ServerCallContext"/> stub that pre-populates the
/// <c>x-apb-user-id</c> request header so service methods can call
/// <c>context.GetUserId()</c> without a real gRPC channel.
/// </summary>
public sealed class FakeServerCallContext : ServerCallContext
{
private readonly Metadata _requestHeaders;
private readonly Metadata _responseTrailers = [];
private readonly CancellationToken _cancellationToken;
private FakeServerCallContext(long userId, CancellationToken cancellationToken = default)
{
_requestHeaders = [new Metadata.Entry("x-apb-user-id", userId.ToString())];
_cancellationToken = cancellationToken;
}
public static FakeServerCallContext For(long userId, CancellationToken cancellationToken = default)
=> new(userId, cancellationToken);
protected override string MethodCore => string.Empty;
protected override string HostCore => string.Empty;
protected override string PeerCore => string.Empty;
protected override DateTime DeadlineCore => DateTime.MaxValue;
protected override Metadata RequestHeadersCore => _requestHeaders;
protected override CancellationToken CancellationTokenCore => _cancellationToken;
protected override Metadata ResponseTrailersCore => _responseTrailers;
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore => new(string.Empty, []);
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
throw new NotImplementedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) =>
throw new NotImplementedException();
}

View File

@@ -0,0 +1,32 @@
using MariesWonderland.Configuration;
using MariesWonderland.Data;
using Microsoft.Extensions.Configuration;
namespace MariesWonderland.Tests.Infrastructure;
/// <summary>
/// Shared fixture that loads the master database and game config once per test collection.
/// Use as <c>IClassFixture&lt;MasterDatabaseFixture&gt;</c> on test classes that need master data.
/// </summary>
public sealed class MasterDatabaseFixture : IDisposable
{
public DarkMasterMemoryDatabase MasterDb { get; }
public GameConfig GameConfig { get; }
public MasterDatabaseFixture()
{
var config = new ConfigurationBuilder()
.SetBasePath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "src"))
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true)
.Build();
var options = config.GetSection(ServerOptions.SectionName).Get<ServerOptions>()!;
var binPath = Path.Combine(options.Paths.MasterDatabase, $"{options.Data.LatestMasterDataVersion}.bin.e");
MasterDb = BinaryMasterDataLoader.Load(binPath);
GameConfig = GameConfig.From(MasterDb.EntityMConfig);
}
public void Dispose() { }
}

View File

@@ -0,0 +1,37 @@
using MariesWonderland.Data;
namespace MariesWonderland.Tests.Infrastructure;
/// <summary>
/// Base class for service-level tests. Concrete test classes should implement
/// <c>IClassFixture&lt;MasterDatabaseFixture&gt;</c> and pass the fixture here.
/// </summary>
public abstract class ServiceTestBase
{
protected DarkMasterMemoryDatabase MasterDb { get; }
protected GameConfig GameConfig { get; }
protected ServiceTestBase(MasterDatabaseFixture fixture)
{
MasterDb = fixture.MasterDb;
GameConfig = fixture.GameConfig;
}
/// <summary>Creates a fresh empty user database.</summary>
protected static DarkUserMemoryDatabase CreateUserDb() => new();
/// <summary>
/// Creates a <see cref="UserDataStore"/> pre-loaded with the given user database
/// so that <c>store.GetOrCreate(userId)</c> returns <paramref name="userDb"/>.
/// </summary>
protected static UserDataStore CreateStore(long userId, DarkUserMemoryDatabase userDb, DarkMasterMemoryDatabase masterDb)
{
var store = new UserDataStore(masterDb);
store.Set(userId, userDb);
return store;
}
/// <summary>Shorthand for <see cref="FakeServerCallContext.For"/>.</summary>
protected static FakeServerCallContext ContextFor(long userId = 1)
=> FakeServerCallContext.For(userId);
}

View File

@@ -0,0 +1,57 @@
using MariesWonderland.Data;
using MariesWonderland.Interceptors;
using MariesWonderland.Proto.User;
using MariesWonderland.Tests.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace MariesWonderland.Tests.Interceptors;
public class AutoSaveInterceptorTests : InterceptorTestBase
{
private static readonly ILogger<AutoSaveInterceptor> Logger = NullLogger<AutoSaveInterceptor>.Instance;
private static AutoSaveInterceptor CreateInterceptor(UserDataStore store) =>
new(store, Logger);
/// <summary>
/// Verifies that the interceptor returns the continuation's response unchanged.
/// </summary>
[Fact]
public async Task ContinuationResultIsReturned()
{
var store = CreateEmptyStore();
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 1);
var expected = new SetUserNameResponse();
var result = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() => expected);
Assert.Same(expected, result);
}
/// <summary>
/// Verifies that when the userId is valid but the user is not in the store,
/// the interceptor still returns the response without crashing.
/// </summary>
[Fact]
public async Task UnknownUser_ContinuationStillCalled()
{
var store = CreateEmptyStore();
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 999);
var expected = new SetUserNameResponse();
var result = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() => expected);
Assert.Same(expected, result);
}
}

View File

@@ -0,0 +1,70 @@
using MariesWonderland.Interceptors;
using MariesWonderland.Proto.User;
using MariesWonderland.Tests.Infrastructure;
namespace MariesWonderland.Tests.Interceptors;
public class CommonHeaderInterceptorTests : InterceptorTestBase
{
private static readonly CommonHeaderInterceptor Interceptor = new();
/// <summary>
/// Verifies that after handling a unary call the interceptor adds the
/// x-apb-response-datetime trailer to the response metadata.
/// </summary>
[Fact]
public async Task AddsResponseDatetimeTrailer()
{
var context = FakeServerCallContext.For(userId: 1);
await CallInterceptor(Interceptor,
new SetUserNameRequest(),
context,
() => new SetUserNameResponse());
var trailer = context.ResponseTrailers
.FirstOrDefault(m => m.Key == "x-apb-response-datetime");
Assert.NotNull(trailer);
}
/// <summary>
/// Verifies that the x-apb-response-datetime trailer value is a valid
/// Unix timestamp in milliseconds and is within 5 seconds of the current time.
/// </summary>
[Fact]
public async Task ResponseDatetimeIsValidRecentUnixTimestamp()
{
var context = FakeServerCallContext.For(userId: 1);
var before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await CallInterceptor(Interceptor,
new SetUserNameRequest(),
context,
() => new SetUserNameResponse());
var after = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var trailer = context.ResponseTrailers
.First(m => m.Key == "x-apb-response-datetime");
var timestamp = long.Parse(trailer.Value);
Assert.InRange(timestamp, before, after + 5000);
}
/// <summary>
/// Verifies that the interceptor returns the continuation's response unchanged.
/// </summary>
[Fact]
public async Task ResponseIsReturnedUnchanged()
{
var context = FakeServerCallContext.For(userId: 1);
var expected = new SetUserNameResponse();
var result = await CallInterceptor(Interceptor,
new SetUserNameRequest(),
context,
() => expected);
Assert.Same(expected, result);
}
}

View File

@@ -0,0 +1,194 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Interceptors;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.User;
using MariesWonderland.Tests.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace MariesWonderland.Tests.Interceptors;
public class DiffInterceptorTests : InterceptorTestBase
{
private static readonly ILogger<DiffInterceptor> Logger = NullLogger<DiffInterceptor>.Instance;
private static DiffInterceptor CreateInterceptor(UserDataStore store) =>
new(store, Logger);
private record NoopResponse;
/// <summary>
/// Verifies that when the response type has no DiffUserData property,
/// the interceptor passes through without modification and returns the
/// continuation's result unchanged.
/// </summary>
[Fact]
public async Task ResponseWithoutDiffProperty_PassesThroughWithoutModification()
{
var store = CreateEmptyStore();
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 1);
var expected = new NoopResponse();
var result = await CallInterceptor(
interceptor,
new object(),
context,
() => expected);
Assert.Same(expected, result);
}
/// <summary>
/// Verifies that when the user's database is not modified during the call,
/// the DiffUserData map on the response remains empty and no
/// x-apb-update-user-data-names trailer is added.
/// </summary>
[Fact]
public async Task KnownUser_DbUnchanged_DiffIsEmpty_NoTrailer()
{
var userDb = new DarkUserMemoryDatabase();
var store = CreateStoreWithUser(1, userDb);
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 1);
var response = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() => new SetUserNameResponse());
Assert.Empty(response.DiffUserData);
var trailer = context.ResponseTrailers
.FirstOrDefault(m => m.Key == "x-apb-update-user-data-names");
Assert.Null(trailer);
}
/// <summary>
/// Verifies that when the continuation modifies the user's database
/// (adds a weapon record), the DiffUserData map on the response
/// contains the corresponding "IUserWeapon" key.
/// </summary>
[Fact]
public async Task KnownUser_DbModified_DiffPopulated()
{
var userDb = new DarkUserMemoryDatabase();
var store = CreateStoreWithUser(1, userDb);
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 1);
var response = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() =>
{
userDb.EntityIUserWeapon.Add(new EntityIUserWeapon
{
UserId = 1,
UserWeaponUuid = "test-uuid",
WeaponId = 100,
Level = 1
});
return new SetUserNameResponse();
});
Assert.Contains("IUserWeapon", response.DiffUserData.Keys);
}
/// <summary>
/// Verifies that when the continuation modifies two different tables,
/// the x-apb-update-user-data-names trailer contains both table names
/// comma-separated and sorted alphabetically.
/// </summary>
[Fact]
public async Task KnownUser_DbModified_TrailerContainsSortedTableNames()
{
var userDb = new DarkUserMemoryDatabase();
var store = CreateStoreWithUser(1, userDb);
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 1);
var response = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() =>
{
userDb.EntityIUserWeapon.Add(new EntityIUserWeapon
{
UserId = 1,
UserWeaponUuid = "test-uuid",
WeaponId = 100,
Level = 1
});
userDb.EntityIUserStatus.Add(new EntityIUserStatus
{
UserId = 1,
Level = 1,
Exp = 0,
StaminaMilliValue = 60000,
StaminaUpdateDatetime = 0
});
return new SetUserNameResponse();
});
var trailer = context.ResponseTrailers
.FirstOrDefault(m => m.Key == "x-apb-update-user-data-names");
Assert.NotNull(trailer);
// "IUserStatus" comes before "IUserWeapon" alphabetically
Assert.Equal("IUserStatus,IUserWeapon", trailer.Value);
}
/// <summary>
/// Verifies the RegisterUser path: when userId is 0 in the request headers,
/// the interceptor runs the continuation first, extracts the newly assigned
/// UserId from the response, and diffs the full state against an empty baseline.
/// </summary>
[Fact]
public async Task RegisterUser_FullStateDiffed()
{
var userDb = new DarkUserMemoryDatabase();
userDb.EntityIUser.Add(new EntityIUser
{
UserId = 42,
PlayerId = 1,
RegisterDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
var store = CreateStoreWithUser(42, userDb);
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 0);
var response = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() => new AuthUserResponse { UserId = 42 });
Assert.NotEmpty(response.DiffUserData);
}
/// <summary>
/// Verifies that when the user is not in the store, the interceptor
/// gracefully handles it: DiffUserData remains empty and no exception is thrown.
/// </summary>
[Fact]
public async Task UserNotInStore_NoSnapshotTaken_DiffIsEmpty()
{
var store = CreateEmptyStore();
var interceptor = CreateInterceptor(store);
var context = FakeServerCallContext.For(userId: 99);
var response = await CallInterceptor(
interceptor,
new SetUserNameRequest(),
context,
() => new SetUserNameResponse());
Assert.Empty(response.DiffUserData);
}
}

View File

@@ -0,0 +1,49 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
using MariesWonderland.Data;
using MariesWonderland.Tests.Infrastructure;
namespace MariesWonderland.Tests.Interceptors;
/// <summary>
/// Base class for interceptor tests. Provides shared helpers for invoking
/// an interceptor's unary handler directly and constructing empty user stores.
/// </summary>
public abstract class InterceptorTestBase
{
/// <summary>
/// Invokes an interceptor's UnaryServerHandler directly, bypassing the gRPC pipeline.
/// The continuation simply calls <paramref name="continuationBody"/> and returns its result.
/// </summary>
protected static Task<TResponse> CallInterceptor<TRequest, TResponse>(
Interceptor interceptor,
TRequest request,
ServerCallContext context,
Func<TResponse> continuationBody)
where TRequest : class
where TResponse : class
{
return interceptor.UnaryServerHandler(
request,
context,
(req, ctx) => Task.FromResult(continuationBody()));
}
/// <summary>Creates a <see cref="UserDataStore"/> with no users registered.</summary>
protected static UserDataStore CreateEmptyStore() =>
new(new DarkMasterMemoryDatabase());
/// <summary>
/// Creates a <see cref="UserDataStore"/> pre-loaded with the given user database.
/// </summary>
protected static UserDataStore CreateStoreWithUser(long userId, DarkUserMemoryDatabase userDb)
{
var store = new UserDataStore(new DarkMasterMemoryDatabase());
store.Set(userId, userDb);
return store;
}
/// <summary>Shorthand for <see cref="FakeServerCallContext.For"/>.</summary>
protected static FakeServerCallContext ContextFor(long userId) =>
FakeServerCallContext.For(userId);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<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.Extensions.Logging.Abstractions" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\MariesWonderland.csproj" />
</ItemGroup>
</Project>