mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
688 lines
28 KiB
C#
688 lines
28 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Profile> 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<string[]>(Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<BadRequestObjectResult>(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<OkObjectResult>(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<BadRequestObjectResult>(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<RadicadeServer.RecNet.Avatar.Avatar>();
|
|
Assert.NotNull(expected);
|
|
Assert.Equal(expected!.SkinColor, avatar.SkinColor);
|
|
Assert.Equal(expected.HairColor, avatar.HairColor);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReceivesGameplayInvites_AnswersBareTrue()
|
|
{
|
|
var result = Assert.IsType<OkObjectResult>(
|
|
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<RadicadeServer.RecNet.Auth.PlayerData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The account write the new-user flow makes. Its age page hands the answer to
|
|
/// <c>accounts/account/me</c> as <c>birthdayUTC</c> - 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<AccountsAPI>(nameof(AccountsAPI.SetBannerImage), "account/me/bannerimage", "HttpPutAttribute");
|
|
AssertRoute<AccountsAPI>(nameof(AccountsAPI.UpdateAccount), "account/me", "HttpPostAttribute");
|
|
AssertRoute<AccountsAPI>(nameof(AccountsAPI.UpdateAccount), "account/me", "HttpPutAttribute");
|
|
AssertRoute<AccountsAPI>(nameof(AccountsAPI.SetRecentHistoryVisibility), "accountprivacysettings/recenthistoryvisibility", "HttpPutAttribute");
|
|
AssertRoute<AccountsAPI>(nameof(AccountsAPI.GetAccountPrivacySettings), "accountprivacysettings/{accountId:long}", "HttpGetAttribute");
|
|
AssertRoute<PlatformNotificationsAPI>(nameof(PlatformNotificationsAPI.Receives), "accounts/{accountId:long}/receives/{notificationType}", "HttpGetAttribute");
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarV2IsAlsoMappedByAccountId()
|
|
{
|
|
var route = typeof(AvatarV2).GetCustomAttributes<RouteAttribute>()
|
|
.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<HttpGetAttribute>()!.Template);
|
|
}
|
|
|
|
private static void AssertRoute<TController>(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);
|
|
}
|
|
}
|