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); } }