diff --git a/src/Data/UserDataStore.cs b/src/Data/UserDataStore.cs
index b21eb73..661d398 100644
--- a/src/Data/UserDataStore.cs
+++ b/src/Data/UserDataStore.cs
@@ -92,6 +92,24 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb)
public bool TryGet(long userId, out DarkUserMemoryDatabase db)
=> _users.TryGetValue(userId, out db!);
+ ///
+ /// Finds the user database whose EntityIUser record matches the given playerId.
+ /// Returns false if no user has that playerId.
+ ///
+ public bool TryGetByPlayerId(long playerId, out DarkUserMemoryDatabase db)
+ {
+ foreach (var (_, userDb) in _users)
+ {
+ if (userDb.EntityIUser.Any(u => u.PlayerId == playerId))
+ {
+ db = userDb;
+ return true;
+ }
+ }
+ db = null!;
+ return false;
+ }
+
///
/// Stores a user database, replacing any existing one for that userId.
///
diff --git a/src/Services/UserService.cs b/src/Services/UserService.cs
index d45afb0..c0b8bc5 100644
--- a/src/Services/UserService.cs
+++ b/src/Services/UserService.cs
@@ -11,6 +11,9 @@ namespace MariesWonderland.Services;
public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWonderland.Proto.User.UserService.UserServiceBase
{
+ public const string AndroidApiKey = "1234567890";
+ public const string AndroidNonce = "Mama";
+
private readonly UserDataStore _store = store;
private readonly UserDataSeeder _seeder = seeder;
@@ -19,8 +22,8 @@ public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWon
{
return Task.FromResult(new GetAndroidArgsResponse
{
- ApiKey = "1234567890",
- Nonce = "Mama"
+ ApiKey = AndroidApiKey,
+ Nonce = AndroidNonce
});
}
@@ -166,41 +169,33 @@ public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWon
/// Returns a player's profile including name, level, favorite costume, and lead deck character.
public override Task GetUserProfile(GetUserProfileRequest request, ServerCallContext context)
{
- long userId = request.PlayerId != 0 ? request.PlayerId : context.GetUserId();
+ long callerUserId = context.GetUserId();
- if (!_store.TryGet(userId, out DarkUserMemoryDatabase userDb))
- {
- return Task.FromResult(new GetUserProfileResponse
- {
- LatestUsedDeck = new ProfileDeck { Power = 100 },
- PvpInfo = new ProfilePvpInfo(),
- GamePlayHistory = new GamePlayHistory
- {
- HistoryItem = { },
- HistoryCategoryGraphItem = { }
- }
- });
- }
+ if (!_store.TryGetByPlayerId(request.PlayerId, out DarkUserMemoryDatabase targetDb))
+ return Task.FromResult(new GetUserProfileResponse());
- EntityIUserProfile? profile = userDb.EntityIUserProfile.FirstOrDefault(p => p.UserId == userId);
- EntityIUserStatus? status = userDb.EntityIUserStatus.FirstOrDefault(s => s.UserId == userId);
+ EntityIUserProfile? profile = targetDb.EntityIUserProfile.FirstOrDefault(p => p.UserId == targetDb.UserId);
+ EntityIUserStatus? status = targetDb.EntityIUserStatus.FirstOrDefault(s => s.UserId == targetDb.UserId);
+ bool isOwnProfile = targetDb.UserId == callerUserId;
+ int maxDeckPower = 0;
List deckCharacters = [];
- EntityIUserDeck? deck = userDb.EntityIUserDeck.FirstOrDefault(d =>
+ EntityIUserDeck? deck = targetDb.EntityIUserDeck.FirstOrDefault(d =>
d.DeckType == DeckType.QUEST && d.UserDeckNumber == 1);
if (deck != null && !string.IsNullOrEmpty(deck.UserDeckCharacterUuid01))
{
- EntityIUserDeckCharacter? dc = userDb.EntityIUserDeckCharacter
+ EntityIUserDeckCharacter? dc = targetDb.EntityIUserDeckCharacter
.FirstOrDefault(c => c.UserDeckCharacterUuid == deck.UserDeckCharacterUuid01);
if (dc != null)
{
- int costumeId = userDb.EntityIUserCostume
+ maxDeckPower = dc.Power;
+ int costumeId = targetDb.EntityIUserCostume
.FirstOrDefault(c => c.UserCostumeUuid == dc.UserCostumeUuid)?.CostumeId ?? 0;
- EntityIUserWeapon? weapon = userDb.EntityIUserWeapon
+ EntityIUserWeapon? weapon = targetDb.EntityIUserWeapon
.FirstOrDefault(w => w.UserWeaponUuid == dc.MainUserWeaponUuid);
deckCharacters.Add(new ProfileDeckCharacter
@@ -215,16 +210,22 @@ public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWon
return Task.FromResult(new GetUserProfileResponse
{
Level = status?.Level ?? 0,
- Name = profile?.Name ?? "",
+ Name = profile?.Name ?? string.Empty,
FavoriteCostumeId = profile?.FavoriteCostumeId ?? 0,
- Message = profile?.Message ?? "",
+ Message = profile?.Message ?? string.Empty,
IsFriend = false,
LatestUsedDeck = new ProfileDeck
{
- Power = 100,
+ Power = maxDeckPower,
DeckCharacter = { deckCharacters }
},
- PvpInfo = new ProfilePvpInfo(),
+ PvpInfo = new ProfilePvpInfo()
+ {
+ MaxSeasonRank = targetDb.EntityIUserPvpWeeklyResult
+ .GroupBy(x => x.PvpSeasonId)
+ .DefaultIfEmpty()
+ .Min(x => x?.OrderBy(y => y.PvpWeeklyVersion).Select(y => y.FinalRank).LastOrDefault() ?? 0),
+ },
GamePlayHistory = new GamePlayHistory
{
HistoryItem = { },
diff --git a/tests/Services/UserServiceTests.cs b/tests/Services/UserServiceTests.cs
new file mode 100644
index 0000000..1b5f468
--- /dev/null
+++ b/tests/Services/UserServiceTests.cs
@@ -0,0 +1,411 @@
+using Google.Protobuf.WellKnownTypes;
+using MariesWonderland.Configuration;
+using MariesWonderland.Data;
+using MariesWonderland.Models.Entities;
+using MariesWonderland.Models.Type;
+using MariesWonderland.Proto.User;
+using MariesWonderland.Tests.Infrastructure;
+using Microsoft.Extensions.Options;
+using UserService = MariesWonderland.Services.UserService;
+
+namespace MariesWonderland.Tests.Services;
+
+public class UserServiceTests(MasterDatabaseFixture fixture) : ServiceTestBase(fixture), IClassFixture
+{
+ private const long UserId = 1L;
+
+ private static UserService CreateService(UserDataStore store)
+ {
+ var options = Options.Create(new ServerOptions());
+ var seeder = new UserDataSeeder(options);
+ return new UserService(store, seeder);
+ }
+
+ ///
+ /// Verifies that SetUserName updates the profile name and sets the name update timestamp.
+ ///
+ [Fact]
+ public async Task SetUserName_WithExistingProfile_UpdatesNameAndTimestamp()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile
+ {
+ UserId = UserId,
+ Name = "OldName"
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.SetUserName(
+ new SetUserNameRequest { Name = "NewName" },
+ ContextFor(UserId));
+
+ Assert.NotNull(response);
+ Assert.Equal("NewName", userDb.EntityIUserProfile[0].Name);
+ Assert.True(userDb.EntityIUserProfile[0].NameUpdateDatetime > 0);
+ }
+
+ ///
+ /// Verifies that SetUserSetting updates the notification preference on the setting record.
+ ///
+ [Fact]
+ public async Task SetUserSetting_WithExistingSetting_UpdatesNotifyPurchaseAlert()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUserSetting.Add(new EntityIUserSetting
+ {
+ UserId = UserId,
+ IsNotifyPurchaseAlert = false
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.SetUserSetting(
+ new SetUserSettingRequest { IsNotifyPurchaseAlert = true },
+ ContextFor(UserId));
+
+ Assert.NotNull(response);
+ Assert.True(userDb.EntityIUserSetting[0].IsNotifyPurchaseAlert);
+ }
+
+ ///
+ /// Verifies that SetUserMessage updates the profile message and sets the message update timestamp.
+ ///
+ [Fact]
+ public async Task SetUserMessage_WithExistingProfile_UpdatesMessageAndTimestamp()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile
+ {
+ UserId = UserId,
+ Name = "TestUser"
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.SetUserMessage(
+ new SetUserMessageRequest { Message = "Hello World" },
+ ContextFor(UserId));
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello World", userDb.EntityIUserProfile[0].Message);
+ Assert.True(userDb.EntityIUserProfile[0].MessageUpdateDatetime > 0);
+ }
+
+ ///
+ /// Verifies that SetUserFavoriteCostumeId updates the profile's favorite costume and timestamp.
+ ///
+ [Fact]
+ public async Task SetUserFavoriteCostumeId_WithExistingProfile_UpdatesCostumeIdAndTimestamp()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile
+ {
+ UserId = UserId,
+ Name = "TestUser"
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.SetUserFavoriteCostumeId(
+ new SetUserFavoriteCostumeIdRequest { FavoriteCostumeId = 42 },
+ ContextFor(UserId));
+
+ Assert.NotNull(response);
+ Assert.Equal(42, userDb.EntityIUserProfile[0].FavoriteCostumeId);
+ Assert.True(userDb.EntityIUserProfile[0].FavoriteCostumeIdUpdateDatetime > 0);
+ }
+
+ ///
+ /// Verifies that SetBirthYearMonth updates the user's birth year and month.
+ ///
+ [Fact]
+ public async Task SetBirthYearMonth_WithExistingUser_UpdatesBirthYearAndMonth()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.SetBirthYearMonth(
+ new SetBirthYearMonthRequest { BirthYear = 1990, BirthMonth = 6 },
+ ContextFor(UserId));
+
+ Assert.NotNull(response);
+ Assert.Equal(1990, userDb.EntityIUser[0].BirthYear);
+ Assert.Equal(6, userDb.EntityIUser[0].BirthMonth);
+ }
+
+ ///
+ /// Verifies that GetBirthYearMonth returns the stored birth year and month.
+ ///
+ [Fact]
+ public async Task GetBirthYearMonth_WithExistingUser_ReturnsStoredValues()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser
+ {
+ UserId = UserId,
+ BirthYear = 1985,
+ BirthMonth = 3
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetBirthYearMonth(
+ new Empty(),
+ ContextFor(UserId));
+
+ Assert.Equal(1985, response.BirthYear);
+ Assert.Equal(3, response.BirthMonth);
+ }
+
+ ///
+ /// Verifies that GetChargeMoney returns the stored charge money amount.
+ ///
+ [Fact]
+ public async Task GetChargeMoney_WithExistingSUser_ReturnsChargeMoneyThisMonth()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntitySUser.Add(new EntitySUser
+ {
+ UserId = UserId,
+ ChargeMoneyThisMonth = 500
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetChargeMoney(
+ new Empty(),
+ ContextFor(UserId));
+
+ Assert.Equal(500, response.ChargeMoneyThisMonth);
+ }
+
+ ///
+ /// Verifies that GetChargeMoney returns zero when no EntitySUser record exists.
+ ///
+ [Fact]
+ public async Task GetChargeMoney_WithNoSUser_ReturnsZero()
+ {
+ var userDb = CreateUserDb();
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetChargeMoney(
+ new Empty(),
+ ContextFor(UserId));
+
+ Assert.Equal(0, response.ChargeMoneyThisMonth);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsLevelFromStatus()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserStatus.Add(new EntityIUserStatus { UserId = UserId, Level = 42 });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal(42, response.Level);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsNameFromProfile()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile { UserId = UserId, Name = "TestPlayer" });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal("TestPlayer", response.Name);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsFavoriteCostumeIdFromProfile()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile { UserId = UserId, FavoriteCostumeId = 99 });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal(99, response.FavoriteCostumeId);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsMessageFromProfile()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserProfile.Add(new EntityIUserProfile { UserId = UserId, Message = "Hello!" });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal("Hello!", response.Message);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsPopulatedLatestUsedDeck()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserDeck.Add(new EntityIUserDeck
+ {
+ UserId = UserId,
+ DeckType = DeckType.QUEST,
+ UserDeckNumber = 1,
+ UserDeckCharacterUuid01 = "dc-uuid"
+ });
+ userDb.EntityIUserDeckCharacter.Add(new EntityIUserDeckCharacter
+ {
+ UserId = UserId,
+ UserDeckCharacterUuid = "dc-uuid",
+ Power = 5000,
+ UserCostumeUuid = "c-uuid",
+ MainUserWeaponUuid = "w-uuid"
+ });
+ userDb.EntityIUserCostume.Add(new EntityIUserCostume
+ {
+ UserId = UserId,
+ UserCostumeUuid = "c-uuid",
+ CostumeId = 200100
+ });
+ userDb.EntityIUserWeapon.Add(new EntityIUserWeapon
+ {
+ UserId = UserId,
+ UserWeaponUuid = "w-uuid",
+ WeaponId = 300100,
+ Level = 15
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal(5000, response.LatestUsedDeck.Power);
+ Assert.Single(response.LatestUsedDeck.DeckCharacter);
+ Assert.Equal(200100, response.LatestUsedDeck.DeckCharacter[0].CostumeId);
+ Assert.Equal(300100, response.LatestUsedDeck.DeckCharacter[0].MainWeaponId);
+ Assert.Equal(15, response.LatestUsedDeck.DeckCharacter[0].MainWeaponLevel);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_ReturnsMaxSeasonRankFromPvpResults()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId, PlayerId = 12345L });
+ userDb.EntityIUserPvpWeeklyResult.Add(new EntityIUserPvpWeeklyResult
+ {
+ UserId = UserId,
+ PvpSeasonId = 1,
+ PvpWeeklyVersion = 1,
+ FinalRank = 50
+ });
+ userDb.EntityIUserPvpWeeklyResult.Add(new EntityIUserPvpWeeklyResult
+ {
+ UserId = UserId,
+ PvpSeasonId = 1,
+ PvpWeeklyVersion = 2,
+ FinalRank = 30
+ });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 12345L },
+ ContextFor(UserId));
+
+ Assert.Equal(30, response.PvpInfo.MaxSeasonRank);
+ }
+
+ [Fact]
+ public async Task GetUserProfile_UnknownPlayerId_ReturnsEmptyResponse()
+ {
+ var userDb = CreateUserDb();
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetUserProfile(
+ new GetUserProfileRequest { PlayerId = 99999L },
+ ContextFor(UserId));
+
+ Assert.Equal(string.Empty, response.Name);
+ Assert.Equal(0, response.Level);
+ }
+
+ [Fact]
+ public async Task GetAndroidArgs_ReturnsExpectedApiKeyAndNonce()
+ {
+ var store = CreateStore(UserId, CreateUserDb(), MasterDb);
+ var service = CreateService(store);
+
+ var response = await service.GetAndroidArgs(new GetAndroidArgsRequest(), ContextFor(UserId));
+
+ Assert.Equal(UserService.AndroidApiKey, response.ApiKey);
+ Assert.Equal(UserService.AndroidNonce, response.Nonce);
+ }
+
+ [Fact]
+ public async Task GameStart_SetsGameStartDatetimeOnEntityIUser()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ await service.GameStart(new Empty(), ContextFor(UserId));
+
+ Assert.True(userDb.EntityIUser[0].GameStartDatetime > 0);
+ }
+
+ [Fact]
+ public async Task GameStart_CreatesEntityIUserGemIfNoneExists()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ await service.GameStart(new Empty(), ContextFor(UserId));
+
+ Assert.Single(userDb.EntityIUserGem);
+ Assert.Equal(0, userDb.EntityIUserGem[0].PaidGem);
+ Assert.Equal(0, userDb.EntityIUserGem[0].FreeGem);
+ }
+
+ [Fact]
+ public async Task GameStart_DoesNotCreateDuplicateEntityIUserGem()
+ {
+ var userDb = CreateUserDb();
+ userDb.EntityIUser.Add(new EntityIUser { UserId = UserId });
+ userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = UserId, PaidGem = 100, FreeGem = 50 });
+ var store = CreateStore(UserId, userDb, MasterDb);
+ var service = CreateService(store);
+
+ await service.GameStart(new Empty(), ContextFor(UserId));
+
+ Assert.Single(userDb.EntityIUserGem);
+ Assert.Equal(100, userDb.EntityIUserGem[0].PaidGem);
+ }
+}