using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Accounts;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth;
using Xunit;
namespace RadicadeServer.Tests;
///
/// The four requests the 2023 client makes about its own profile that Radicade had no
/// route for, so each failed on the wire in a different way:
///
/// PUT accounts/account/me/bannerimage -> 404
/// PUT accounts/accountprivacysettings/recenthistoryvisibility -> 405
/// GET platformnotifications/accounts/{id}/receives/GameplayInvites -> 404
/// GET econ/api/avatar/v2/{accountId} -> 404
///
/// The route assertions below exist because a controller-level test cannot see a missing
/// or mis-shaped route - that is exactly the failure being fixed.
///
public class ProfileSettingsTests
{
private const long AccountId = 6;
private static RecDBContext CreateDb()
{
var options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new RecDBContext(options);
}
private static AccountsAPI CreateAccountsController(RecDBContext db) => new(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = CreateUser() }
}
};
private static ClaimsPrincipal CreateUser()
{
var identity = new ClaimsIdentity("TestAuth");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, AccountId.ToString()));
return new ClaimsPrincipal(identity);
}
private static void SetBody(Controller controller, string? body, string contentType, string query = "")
{
var context = controller.ControllerContext.HttpContext;
context.Request.ContentType = contentType;
context.Request.QueryString = new QueryString(query);
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body ?? string.Empty));
}
private static async Task SeedProfileAsync(RecDBContext db, bool recentHistoryVisible = true, string? bannerImage = null)
{
var profile = new Profile
{
accountId = AccountId,
username = "bob",
displayName = "Bob",
profileImage = "img.png",
isRecentHistoryVisible = recentHistoryVisible,
bannerImage = bannerImage
};
db.Profiles.Add(profile);
await db.SaveChangesAsync();
return profile;
}
private static JsonDocument Serialize(object? value) =>
JsonDocument.Parse(JsonSerializer.Serialize(value));
[Theory]
[InlineData(true, "{\"pronounFlags\":3}", "application/json", "")]
[InlineData(true, "pronounFlags=3", "application/x-www-form-urlencoded", "")]
[InlineData(true, "", "application/json", "?pronounFlags=3")]
[InlineData(true, "{\"pronouns\":3}", "application/json", "")]
[InlineData(true, "{\"personalPronouns\":3}", "application/json", "")]
[InlineData(true, "pronouns=3", "application/x-www-form-urlencoded", "")]
[InlineData(true, "value=3", "application/x-www-form-urlencoded", "")]
[InlineData(true, "", "application/json", "?pronouns=3")]
[InlineData(false, "{\"identityFlags\":3}", "application/json", "")]
[InlineData(false, "identityFlags=3", "application/x-www-form-urlencoded", "")]
[InlineData(false, "value=3", "application/x-www-form-urlencoded", "")]
[InlineData(false, "", "application/json", "?identityFlags=3")]
public async Task IdentitySettings_SaveAndSurviveAccountReload(bool pronouns, string body, string contentType, string query)
{
using var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, body, contentType, query);
var result = pronouns ? await api.SetPersonalPronouns() : await api.SetIdentityFlags();
Assert.True(result.success);
db.ChangeTracker.Clear();
var stored = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal(3, pronouns ? stored.personalPronouns : stored.identityFlags);
var account = await CreateAccountsController(db).GetProfile();
Assert.NotNull(account);
Assert.Equal(3, pronouns ? account.personalPronouns : account.identityFlags);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(4)]
[InlineData(8)]
[InlineData(16)]
[InlineData(32)]
[InlineData(6)]
public async Task PronounFlags_ClientRequestSetsClearsAndCombinesPronouns(int flags)
{
using var db = CreateDb();
var profile = await SeedProfileAsync(db);
await SeedPlayerAsync(db);
profile.personalPronouns = 1;
profile.identityFlags = 5;
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
SetBody(api, $"pronounFlags={flags}", "application/x-www-form-urlencoded");
Assert.True((await api.SetPersonalPronouns()).success);
db.ChangeTracker.Clear();
var account = await CreateAccountsController(db).GetProfile();
Assert.Equal(flags, account!.personalPronouns);
Assert.Equal(5, account.identityFlags);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task IdentitySettings_CanBeClearedExplicitly(bool pronouns)
{
using var db = CreateDb();
var profile = await SeedProfileAsync(db);
profile.personalPronouns = 3;
profile.identityFlags = 5;
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
SetBody(api, pronouns ? "{\"pronouns\":0}" : "{\"identityFlags\":0}", "application/json");
var result = pronouns ? await api.SetPersonalPronouns() : await api.SetIdentityFlags();
Assert.True(result.success);
var stored = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal(pronouns ? 0 : 3, stored.personalPronouns);
Assert.Equal(pronouns ? 5 : 0, stored.identityFlags);
}
[Theory]
[InlineData("{}")]
[InlineData("{\"value\":null}")]
[InlineData("{\"value\":\"invalid\"}")]
[InlineData("{\"value\":-1}")]
[InlineData("{\"value\":2147483648}")]
[InlineData("{\"unrelated\":0}")]
public async Task IdentitySettings_InvalidWritesDoNotClearExistingSelections(string body)
{
using var db = CreateDb();
var profile = await SeedProfileAsync(db);
profile.personalPronouns = 3;
profile.identityFlags = 5;
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
SetBody(api, body, "application/json");
Assert.False((await api.SetPersonalPronouns()).success);
SetBody(api, body, "application/json");
Assert.False((await api.SetIdentityFlags()).success);
var stored = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal(3, stored.personalPronouns);
Assert.Equal(5, stored.identityFlags);
}
[Fact]
public async Task IdentitySettings_MissingProfileDoesNotReportSuccess()
{
using var db = CreateDb();
var api = CreateAccountsController(db);
SetBody(api, "{\"value\":3}", "application/json");
Assert.False((await api.SetPersonalPronouns()).success);
Assert.False((await api.SetIdentityFlags()).success);
}
[Fact]
public async Task EmojiPicker_ReturnsSelectableStringsThatSaveAndReload()
{
using var db = CreateDb();
var catalog = new RadicadeServer.API.Compatibility.CompatibilityAPI(db);
var emojis = Assert.IsType(Assert.IsType(catalog.WhitelistedEmojis()).Value);
Assert.Contains("😀", emojis);
Assert.Contains("🏳️🌈", emojis);
Assert.Equal(emojis.Length, emojis.Distinct().Count());
Assert.All(emojis, emoji => Assert.False(string.IsNullOrWhiteSpace(emoji)));
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, JsonSerializer.Serialize(new { emoji = emojis[0] }), "application/json");
Assert.Equal(emojis[0], (await api.SetDisplayEmoji())!.displayEmoji);
db.ChangeTracker.Clear();
Assert.Equal(emojis[0], (await CreateAccountsController(db).GetProfile())!.displayEmoji);
}
[Fact]
public async Task PrivacySettings_ReportsTheStoredRecentHistoryVisibility()
{
var db = CreateDb();
await SeedProfileAsync(db, recentHistoryVisible: false);
var api = CreateAccountsController(db);
var ok = Assert.IsType(await api.GetAccountPrivacySettings(AccountId));
using var json = Serialize(ok.Value);
// Both spellings the client's string table carries, because its two-field DTO for
// this object could bind either one.
Assert.False(json.RootElement.GetProperty("isRecentHistoryVisible").GetBoolean());
Assert.False(json.RootElement.GetProperty("isrecenthistoryvisible").GetBoolean());
Assert.Equal(AccountId, json.RootElement.GetProperty("accountId").GetInt64());
}
[Theory]
[InlineData("{\"isRecentHistoryVisible\":false}", "application/json")]
[InlineData("{\"IsRecentHistoryVisible\":false}", "application/json")]
[InlineData("{\"isrecenthistoryvisible\":\"false\"}", "application/json")]
[InlineData("{\"isrecenthistoryvisible\":0}", "application/json")]
[InlineData("{\"recentHistoryVisibility\":false}", "application/json")]
[InlineData("{\"isVisible\":false}", "application/json")]
[InlineData("{\"whatever\":false}", "application/json")]
[InlineData("isRecentHistoryVisible=false", "application/x-www-form-urlencoded")]
[InlineData("isrecenthistoryvisible=False", "application/x-www-form-urlencoded")]
[InlineData("false", "text/plain")]
[InlineData("0", "text/plain")]
[InlineData("", "application/json", "?isRecentHistoryVisible=false")]
[InlineData("", "application/x-www-form-urlencoded", "?value=false")]
public async Task RecentHistoryVisibility_AcceptsEveryShapeTheClientMightSend(
string body, string contentType, string query = "")
{
var db = CreateDb();
await SeedProfileAsync(db);
var api = CreateAccountsController(db);
SetBody(api, body, contentType, query);
var result = await api.SetRecentHistoryVisibility();
Assert.IsType(result);
Assert.False((await db.Profiles.SingleAsync()).isRecentHistoryVisible);
}
[Fact]
public async Task RecentHistoryVisibility_EnablesAsWellAsDisables()
{
var db = CreateDb();
await SeedProfileAsync(db, recentHistoryVisible: false);
var api = CreateAccountsController(db);
SetBody(api, "{\"isRecentHistoryVisible\":true}", "application/json");
Assert.IsType(await api.SetRecentHistoryVisibility());
Assert.True((await db.Profiles.SingleAsync()).isRecentHistoryVisible);
}
[Fact]
public async Task RecentHistoryVisibility_RejectsAnUnreadableBodyInsteadOfStoringFalse()
{
var db = CreateDb();
await SeedProfileAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"unrelated\":\"text\"}", "application/json");
Assert.IsType(await api.SetRecentHistoryVisibility());
// A guess (false) here would have looked like a working toggle that resets itself.
Assert.True((await db.Profiles.SingleAsync()).isRecentHistoryVisible);
}
[Fact]
public async Task BannerImage_StoresTheUploadedKey()
{
var db = CreateDb();
await SeedProfileAsync(db);
var api = CreateAccountsController(db);
const string key = "sharecamera/2026-09-19/9f2c.jpg";
var result = await api.SetBannerImage(key);
Assert.IsType(result);
Assert.Equal(key, (await db.Profiles.AsNoTracking().SingleAsync()).bannerImage);
}
[Fact]
public async Task BannerImage_RejectsAnEmptyKeyWithoutWipingTheStoredBanner()
{
var db = CreateDb();
await SeedProfileAsync(db, bannerImage: "sharecamera/2026-01-01/old.jpg");
var api = CreateAccountsController(db);
Assert.IsType(await api.SetBannerImage(string.Empty));
Assert.Equal("sharecamera/2026-01-01/old.jpg", (await db.Profiles.AsNoTracking().SingleAsync()).bannerImage);
}
[Fact]
public async Task AvatarV2_ReturnsAnotherPlayersRenderSubset()
{
var db = CreateDb();
db.Avatars.Add(new PlayerAvatar
{
AccountId = AccountId,
Avatar = new RadicadeServer.RecNet.Avatar.Avatar
{
OutfitSelections = "selections",
FaceFeatures = "{\"ver\":3}",
SkinColor = "skin",
HairColor = "hair"
}.ToJson(),
Outfits = "[]"
});
await db.SaveChangesAsync();
var avatar = await new AvatarV2(db).GetAvatarForAccount(AccountId);
Assert.Equal("selections", avatar.OutfitSelections);
Assert.Equal("skin", avatar.SkinColor);
Assert.Equal("hair", avatar.HairColor);
}
[Fact]
public async Task AvatarV2_FallsBackToTheDefaultOutfitForAPlayerWithoutOne()
{
var avatar = await new AvatarV2(CreateDb()).GetAvatarForAccount(AccountId);
// A null body is what left other players undrawable, so the fallback has to be a
// real avatar rather than an empty object.
var expected = DBConfig.DefaultAvatar.FromJson();
Assert.NotNull(expected);
Assert.Equal(expected!.SkinColor, avatar.SkinColor);
Assert.Equal(expected.HairColor, avatar.HairColor);
}
[Fact]
public void ReceivesGameplayInvites_AnswersBareTrue()
{
var result = Assert.IsType(
new PlatformNotificationsAPI().Receives(AccountId, "GameplayInvites"));
// The client's parser reads a bare boolean; an envelope object fails it.
Assert.Equal(true, result.Value);
}
private static async Task SeedPlayerAsync(
RecDBContext db, DateTime? birthday = null)
{
var player = new RadicadeServer.RecNet.Auth.PlayerData
{
AccountId = AccountId,
Hashword = "hashed",
Birthday = birthday
};
db.Players.Add(player);
await db.SaveChangesAsync();
return player;
}
///
/// The account write the new-user flow makes. Its age page hands the answer to
/// accounts/account/me as birthdayUTC - the name this build's own account object
/// carries - and until this route existed that write met the GET-only mapping, so the birthday
/// was dropped and the player was asked for it again on the next boot.
///
[Fact]
public async Task AccountMeWrite_StoresTheBirthdayTheAgePagePosts()
{
var db = CreateDb();
var profile = await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"birthdayUTC\":\"2018-05-04T00:00:00Z\"}", "application/json");
var account = await api.UpdateAccount();
Assert.NotNull(account);
Assert.Equal(new DateTime(2018, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
// Eight years old is the junior split, and the profile the session reads carries it.
Assert.True((await db.Players.AsNoTracking().SingleAsync()).IsJunior);
Assert.True((await db.Profiles.AsNoTracking().SingleAsync()).isJunior);
Assert.True(profile.isJunior);
// Answering with the account is what lets the flow read back what it just set.
Assert.Equal(new DateTime(2018, 5, 4), account!.birthdayUTC!.Value.Date);
Assert.True(account.hasBirthday);
Assert.True(account.isJunior);
}
[Fact]
public async Task AccountMeWrite_ReadsTheBirthdayOutOfAWholeAccountObject()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(
api,
"{\"accountId\":6,\"username\":\"bob\",\"displayName\":\"Bob\",\"bio\":\"hi\"," +
"\"email\":\"bob@example.com\",\"identityFlags\":3,\"birthdayUTC\":\"1999-05-04T00:00:00Z\"}",
"application/json");
Assert.NotNull(await api.UpdateAccount());
Assert.Equal(new DateTime(1999, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
Assert.False((await db.Players.AsNoTracking().SingleAsync()).IsJunior);
}
[Fact]
public async Task AccountMeWrite_AcceptsTheBirthdaySpeltWithoutUtc()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"birthday\":\"1999-05-04T00:00:00Z\"}", "application/json");
Assert.NotNull(await api.UpdateAccount());
Assert.Equal(new DateTime(1999, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
}
[Fact]
public async Task AccountMeWrite_LeavesAnAccountUntouchedWhenTheBodyCarriesNoBirthday()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db, birthday: new DateTime(1999, 5, 4));
var api = CreateAccountsController(db);
SetBody(api, "{\"bio\":\"a bio the flow never sends\"}", "application/json");
// The body is the whole account, so a write that carries no age still has to answer with
// the account rather than fail: the client posts this object for more than the birthday.
var account = await api.UpdateAccount();
Assert.NotNull(account);
Assert.Equal(new DateTime(1999, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
Assert.True(account!.hasBirthday);
}
[Fact]
public async Task AccountMeWrite_DoesNotStoreAnAgeTheDatePickerCouldNotHaveGiven()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"birthdayUTC\":\"1800-01-01T00:00:00Z\"}", "application/json");
Assert.NotNull(await api.UpdateAccount());
// Storing it would make the account 226 years old, and junior state would never come back.
Assert.Null((await db.Players.AsNoTracking().SingleAsync()).Birthday);
}
///
/// The flow's name page writes account/me/displayname, while the title screen greets a new player
/// with @username - so a write that only moved the display name left the player looking at the
/// generated name the create had to fall back to, and the account looked auto-made. While the
/// flow is unfinished, the name it collects is the account's name.
///
[Fact]
public async Task DisplayNameWrite_NamesTheAccountWhileTheFlowIsStillOpen()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
db.PlayerSettings.Add(new PlayerSettings
{
AccountId = AccountId,
Settings = "[{\"Key\":\"Recroom.AccountCreation.HasStarted\",\"Value\":\"True\"}," +
"{\"Key\":\"Recroom.AccountCreation.HasFinished\",\"Value\":\"False\"}]"
});
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
await api.SetDisplayName("ChosenByThePlayer");
var profile = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal("ChosenByThePlayer", profile.username);
Assert.Equal("ChosenByThePlayer", profile.displayName);
}
[Fact]
public async Task DisplayNameWrite_LeavesTheUsernameAloneOnceTheFlowIsOver()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
db.PlayerSettings.Add(new PlayerSettings
{
AccountId = AccountId,
Settings = "[{\"Key\":\"Recroom.AccountCreation.HasStarted\",\"Value\":\"True\"}," +
"{\"Key\":\"Recroom.AccountCreation.HasFinished\",\"Value\":\"True\"}]"
});
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
await api.SetDisplayName("ARenamedDisplayName");
var profile = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal("bob", profile.username);
Assert.Equal("ARenamedDisplayName", profile.displayName);
}
///
/// The flow's name page writes the account object rather than a field of its own, and the create
/// has already minted the account by the time that page is reached. So the chosen name lands
/// here or not at all - without this the player kept the generated name the create fell back to
/// while the flow's own settings said they had chosen one.
///
[Fact]
public async Task AccountMeWrite_KeepsTheNameTheUsernamePageChose()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"username\":\"BobbyTwoShoes\",\"birthdayUTC\":\"1999-05-04T00:00:00Z\"}", "application/json");
Assert.NotNull(await api.UpdateAccount());
var profile = await db.Profiles.AsNoTracking().SingleAsync();
Assert.Equal("BobbyTwoShoes", profile.username);
Assert.Equal("BobbyTwoShoes", profile.displayName);
}
[Fact]
public async Task AccountMeWrite_RefusesANameAnotherAccountAlreadyHas()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
db.Profiles.Add(new Profile
{
accountId = 7,
username = "BobbyTwoShoes",
displayName = "BobbyTwoShoes",
profileImage = "default"
});
await db.SaveChangesAsync();
var api = CreateAccountsController(db);
SetBody(api, "{\"username\":\"BobbyTwoShoes\"}", "application/json");
// The write still answers with the account: the client posts this object for more than the
// name, and a refusal of one field must not look like a failed write of all of them.
Assert.NotNull(await api.UpdateAccount());
Assert.Equal("bob", (await db.Profiles.AsNoTracking().SingleAsync(x => x.accountId == AccountId)).username);
}
///
/// The age page in the flow is three steppers (Month, Day, Year), not a date string. A body
/// carrying them under names of their own used to parse as nothing at all and leave the account
/// unaged with no error the player could see.
///
[Fact]
public async Task AccountMeWrite_ReadsTheBirthdayOutOfTheThreeDatePickerSteppers()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"month\":8,\"day\":30,\"year\":2011}", "application/json");
Assert.NotNull(await api.UpdateAccount());
Assert.Equal(new DateTime(2011, 8, 30), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
// Read as a real date, not as three numbers that happened to be in the body.
Assert.False((await db.Players.AsNoTracking().SingleAsync()).IsJunior);
}
[Fact]
public async Task AccountMeWrite_ReadsTheThreeSteppersOutOfAWholeAccountObjectToo()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "{\"account\":{\"month\":5,\"day\":4,\"year\":1999}}", "application/json");
Assert.NotNull(await api.UpdateAccount());
Assert.Equal(new DateTime(1999, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
}
[Fact]
public async Task AccountMeWrite_ReadsTheBirthdayOutOfAFormBodyToo()
{
var db = CreateDb();
await SeedProfileAsync(db);
await SeedPlayerAsync(db);
var api = CreateAccountsController(db);
SetBody(api, "birthdayUTC=1999-05-04T00:00:00Z", "application/x-www-form-urlencoded");
Assert.NotNull(await api.UpdateAccount());
Assert.Equal(new DateTime(1999, 5, 4), (await db.Players.AsNoTracking().SingleAsync()).Birthday);
}
[Fact]
public void SelfAccount_ReportsTheBirthdayUnderTheNameTheClientReads()
{
var profile = new Profile { accountId = AccountId, username = "bob", displayName = "Bob" };
var player = new RadicadeServer.RecNet.Auth.PlayerData { AccountId = AccountId, Hashword = "x" };
var withoutBirthday = RadicadeServer.Utils.PlayerUtils.BuildSelfAccount(profile, player);
using var beforeJson = Serialize(withoutBirthday);
// No age is reported as no age. This member is what the title screen reads to decide the
// age page has been answered, so the 0001-01-01 placeholder this used to carry - against a
// hasBirthday that said the opposite - is why the page was skipped for every account on
// this server. The key itself stays, because these members bind by position.
Assert.Null(withoutBirthday.birthdayUTC);
Assert.False(withoutBirthday.hasBirthday);
Assert.Equal(JsonValueKind.Null, beforeJson.RootElement.GetProperty("birthdayUTC").ValueKind);
// The one exception is a stored placeholder: the column some half-created accounts carry
// is still not an age, and it must not be reported as one either.
player.Birthday = DateTime.MinValue;
Assert.Null(RadicadeServer.Utils.PlayerUtils.BuildSelfAccount(profile, player).birthdayUTC);
player.Birthday = new DateTime(1999, 5, 4);
var withBirthday = RadicadeServer.Utils.PlayerUtils.BuildSelfAccount(profile, player);
using var afterJson = Serialize(withBirthday);
Assert.Equal(new DateTime(1999, 5, 4), withBirthday.birthdayUTC!.Value.Date);
Assert.True(withBirthday.hasBirthday);
Assert.Equal(1999, afterJson.RootElement.GetProperty("birthdayUTC").GetDateTime().Year);
}
[Fact]
public void NewProfileRoutesAreMappedWhereTheClientRequestsThem()
{
AssertRoute(nameof(AccountsAPI.SetBannerImage), "account/me/bannerimage", "HttpPutAttribute");
AssertRoute(nameof(AccountsAPI.UpdateAccount), "account/me", "HttpPostAttribute");
AssertRoute(nameof(AccountsAPI.UpdateAccount), "account/me", "HttpPutAttribute");
AssertRoute(nameof(AccountsAPI.SetRecentHistoryVisibility), "accountprivacysettings/recenthistoryvisibility", "HttpPutAttribute");
AssertRoute(nameof(AccountsAPI.GetAccountPrivacySettings), "accountprivacysettings/{accountId:long}", "HttpGetAttribute");
AssertRoute(nameof(PlatformNotificationsAPI.Receives), "accounts/{accountId:long}/receives/{notificationType}", "HttpGetAttribute");
}
[Fact]
public void AvatarV2IsAlsoMappedByAccountId()
{
var route = typeof(AvatarV2).GetCustomAttributes()
.Select(attribute => attribute.Template)
.ToList();
Assert.Contains("api/avatar/v2", route);
Assert.Contains("econ/api/avatar/v2", route);
var method = typeof(AvatarV2).GetMethod(nameof(AvatarV2.GetAvatarForAccount))!;
Assert.Equal("{accountId:long}", method.GetCustomAttribute()!.Template);
}
private static void AssertRoute(string action, string template, string verbAttributeName)
{
var method = typeof(TController).GetMethod(action, BindingFlags.Public | BindingFlags.Instance)!;
var attribute = method.GetCustomAttributes(inherit: false)
.FirstOrDefault(candidate => candidate.GetType().Name == verbAttributeName);
Assert.NotNull(attribute);
Assert.Equal(template, attribute!.GetType().GetProperty("Template")!.GetValue(attribute) as string);
}
}