mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-05-06 12:53:38 +02:00
Initial commit
This commit is contained in:
41
tests/Infrastructure/FakeServerCallContext.cs
Normal file
41
tests/Infrastructure/FakeServerCallContext.cs
Normal 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();
|
||||
}
|
||||
32
tests/Infrastructure/MasterDatabaseFixture.cs
Normal file
32
tests/Infrastructure/MasterDatabaseFixture.cs
Normal 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<MasterDatabaseFixture></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() { }
|
||||
}
|
||||
37
tests/Infrastructure/ServiceTestBase.cs
Normal file
37
tests/Infrastructure/ServiceTestBase.cs
Normal 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<MasterDatabaseFixture></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);
|
||||
}
|
||||
57
tests/Interceptors/AutoSaveInterceptorTests.cs
Normal file
57
tests/Interceptors/AutoSaveInterceptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
70
tests/Interceptors/CommonHeaderInterceptorTests.cs
Normal file
70
tests/Interceptors/CommonHeaderInterceptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
194
tests/Interceptors/DiffInterceptorTests.cs
Normal file
194
tests/Interceptors/DiffInterceptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
tests/Interceptors/InterceptorTestBase.cs
Normal file
49
tests/Interceptors/InterceptorTestBase.cs
Normal 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);
|
||||
}
|
||||
35
tests/MariesWonderland.Tests.csproj
Normal file
35
tests/MariesWonderland.Tests.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user