mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1695 lines
73 KiB
C#
1695 lines
73 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.AspNetCore.Routing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RadicadeServer.API.Config;
|
|
using RadicadeServer.API.Events;
|
|
using RadicadeServer.API.Matchmaking;
|
|
using RadicadeServer.API.Notifications;
|
|
using RadicadeServer.API.Relationships;
|
|
using RadicadeServer.API.Relationships.V2;
|
|
using RadicadeServer.Clubs;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Enums;
|
|
using RadicadeServer.RecNet.Enums.Messages;
|
|
using RadicadeServer.RecNet.Enums.Rooms;
|
|
using RadicadeServer.RecNet.Events;
|
|
using RadicadeServer.RecNet.Matchmaking;
|
|
using RadicadeServer.RecNet.Messages;
|
|
using RadicadeServer.RecNet.Relationships;
|
|
using RadicadeServer.RecNet.Responses;
|
|
using RadicadeServer.RecNet.Rooms;
|
|
using RadicadeServer.RoomServer;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
[Collection(DbFactoryCollection.Name)]
|
|
public class InviteFlowTests
|
|
{
|
|
private const long CreatorId = 42;
|
|
private const long InviteeId = 43;
|
|
private const long StrangerId = 44;
|
|
private const long RoomId = 45;
|
|
private const long EventId = 46;
|
|
private const long ClubId = 47;
|
|
|
|
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
|
{
|
|
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
public RecDBContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
private sealed record FactorySnapshot(
|
|
IDbContextFactory<RecDBContext> Notify,
|
|
IDbContextFactory<RecDBContext> Room,
|
|
IDbContextFactory<RecDBContext> Matchmaking);
|
|
|
|
private static FactorySnapshot SetFactories(IDbContextFactory<RecDBContext> factory)
|
|
{
|
|
var previous = new FactorySnapshot(Notify.DbFactory, RoomUtils.DbFactory, MatchmakingUtils.DbFactory);
|
|
Notify.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
MatchmakingUtils.DbFactory = factory;
|
|
return previous;
|
|
}
|
|
|
|
private static void RestoreFactories(FactorySnapshot previous)
|
|
{
|
|
Notify.DbFactory = previous.Notify;
|
|
RoomUtils.DbFactory = previous.Room;
|
|
MatchmakingUtils.DbFactory = previous.Matchmaking;
|
|
}
|
|
|
|
private static async Task<(RecDBContext Db, FactorySnapshot Previous)> CreateContextAsync()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
var previous = SetFactories(factory);
|
|
var db = factory.CreateDbContext();
|
|
|
|
db.Profiles.AddRange(ProfileFor(CreatorId), ProfileFor(InviteeId), ProfileFor(StrangerId));
|
|
db.Players.AddRange(
|
|
new PlayerData { AccountId = CreatorId, Hashword = "test" },
|
|
new PlayerData { AccountId = InviteeId, Hashword = "test" },
|
|
new PlayerData { AccountId = StrangerId, Hashword = "test" });
|
|
await db.SaveChangesAsync();
|
|
return (db, previous);
|
|
}
|
|
|
|
private static void SetUser(Controller controller, long userId)
|
|
{
|
|
var identity = new ClaimsIdentity("TestAuth");
|
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
|
};
|
|
}
|
|
|
|
private static void SetFormBody(Controller controller, string body)
|
|
{
|
|
var request = controller.HttpContext.Request;
|
|
request.ContentType = "application/x-www-form-urlencoded";
|
|
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
|
|
request.ContentLength = request.Body.Length;
|
|
}
|
|
|
|
private static Profile ProfileFor(long id) => new()
|
|
{
|
|
accountId = id,
|
|
username = $"player{id}",
|
|
displayName = $"Player {id}",
|
|
profileImage = string.Empty,
|
|
createdAt = DateTime.UtcNow
|
|
};
|
|
|
|
private static async Task SeedRoomAsync(RecDBContext db, Accessibility accessibility = Accessibility.Public)
|
|
{
|
|
db.Rooms.Add(new RoomData
|
|
{
|
|
RoomId = RoomId,
|
|
Name = "Invite test room",
|
|
CreatorAccountId = CreatorId,
|
|
Accessibility = accessibility,
|
|
MaxPlayers = 8,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
db.SubRooms.Add(new SubRoom
|
|
{
|
|
RoomId = RoomId,
|
|
SubRoomId = 1,
|
|
Name = "Main",
|
|
MaxPlayers = 8,
|
|
Accessibility = Accessibility.Public,
|
|
UnitySceneId = "invite-test-scene"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task<PlayerEventDB> SeedEventAsync(
|
|
RecDBContext db,
|
|
Accessibility eventAccess = Accessibility.Private,
|
|
Accessibility roomAccess = Accessibility.Public)
|
|
{
|
|
await SeedRoomAsync(db, roomAccess);
|
|
var ev = new PlayerEventDB
|
|
{
|
|
PlayerEventId = EventId,
|
|
Name = "Invite test event",
|
|
CreatorPlayerId = CreatorId,
|
|
RoomId = RoomId,
|
|
StartTime = DateTime.UtcNow.AddMinutes(-10),
|
|
EndTime = DateTime.UtcNow.AddHours(1),
|
|
Accessibility = eventAccess
|
|
};
|
|
db.PlayerEvents.Add(ev);
|
|
db.Presences.Add(new RealPresence { playerId = CreatorId, isOnline = true });
|
|
db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
return ev;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BulkEventInvitePersistsPendingResponseAndMessageAndAllowsPrivateEventJoin()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db, Accessibility.Private, Accessibility.Private);
|
|
var controller = new EventsAPI(db);
|
|
SetUser(controller, CreatorId);
|
|
SetFormBody(controller, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}");
|
|
|
|
var result = await controller.BulkInvite();
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
var response = await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId);
|
|
Assert.Equal(EventResponses.Pending, response.Type);
|
|
var message = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.PlayerEventInvitation, message.Type);
|
|
Assert.Equal(EventId, message.PlayerEventId);
|
|
Assert.Equal(CreatorId, message.FromPlayerId);
|
|
Assert.Equal(EventResponses.Pending,
|
|
(await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId)).Type);
|
|
|
|
var invitee = new EventsAPI(db);
|
|
SetUser(invitee, InviteeId);
|
|
Assert.IsType<OkObjectResult>((await invitee.RespondToEventV2(EventId)).Result);
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
Assert.Equal(JoinStatus.Success, match.errorCode);
|
|
Assert.Equal(EventId, match.roomInstance?.eventId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PrivateEventRejectsUninvitedRsvpAndDeclinedInviteeCannotReenter()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db);
|
|
|
|
var strangerRsvp = new EventsAPI(db);
|
|
SetUser(strangerRsvp, StrangerId);
|
|
Assert.IsType<NotFoundResult>((await strangerRsvp.RespondToEventV2(EventId)).Result);
|
|
|
|
var invitedRsvp = new EventsAPI(db);
|
|
SetUser(invitedRsvp, InviteeId);
|
|
Assert.IsType<NotFoundResult>((await invitedRsvp.RespondToEventV2(EventId)).Result);
|
|
|
|
db.Messages.Add(new Message
|
|
{
|
|
ToPlayerId = InviteeId,
|
|
FromPlayerId = CreatorId,
|
|
Type = MessageType.PlayerEventInvitation,
|
|
PlayerEventId = EventId
|
|
});
|
|
db.PlayerEventResponses.Add(new PlayerEventResponse
|
|
{
|
|
PlayerEventId = EventId,
|
|
PlayerId = InviteeId,
|
|
Type = EventResponses.No
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
Assert.Equal(JoinStatus.EventIsPrivate, match.errorCode);
|
|
|
|
var resend = new EventsAPI(db);
|
|
SetUser(resend, CreatorId);
|
|
SetFormBody(resend, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}");
|
|
Assert.IsType<OkObjectResult>(await resend.BulkInvite());
|
|
Assert.Equal(2, await db.Messages.CountAsync(x => x.ToPlayerId == InviteeId && x.PlayerEventId == EventId));
|
|
|
|
var response = await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId);
|
|
Assert.Equal(EventResponses.Pending, response.Type);
|
|
var renewedRsvp = new EventsAPI(db);
|
|
SetUser(renewedRsvp, InviteeId);
|
|
Assert.IsType<OkObjectResult>((await renewedRsvp.RespondToEventV2(EventId)).Result);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GameInviteCarriesInstanceAndRoomIdsAndGrantsOnlyRecipientPrivateAccess()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "private-instance"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = CreatorId,
|
|
roomInstance = instance.roomInstanceId,
|
|
isOnline = true
|
|
});
|
|
db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
var inviter = new MatchmakingAPI(db);
|
|
SetUser(inviter, CreatorId);
|
|
Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success);
|
|
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.GameInviteV2, invite.Type);
|
|
Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite));
|
|
Assert.Equal(RoomId, invite.RoomId);
|
|
|
|
// The client's V2 parser requires this JSON string before its button can join.
|
|
using var data = JsonDocument.Parse(invite.Data);
|
|
Assert.Equal(invite.Id, data.RootElement.GetProperty("InviteId").GetInt64());
|
|
Assert.Equal("Invite test room", data.RootElement.GetProperty("Name").GetString());
|
|
Assert.Equal(0, data.RootElement.GetProperty("InviteMode").GetInt32());
|
|
var inbox = new RadicadeServer.API.Messages.MessagesAPI(db);
|
|
SetUser(inbox, InviteeId);
|
|
await inbox.DeleteMessage(new DeleteMessageRequestDTO { MessageIds = new() { invite.Id } });
|
|
Assert.Empty((await inbox.GetMessages())!);
|
|
Assert.True((await db.Messages.SingleAsync(x => x.Id == invite.Id)).IsDismissed);
|
|
|
|
var joiner = new MatchmakingAPI(db);
|
|
SetUser(joiner, InviteeId);
|
|
var accepted = await joiner.AcceptInviteByMessageId(
|
|
data.RootElement.GetProperty("InviteId").GetInt64(), null);
|
|
Assert.Equal(JoinStatus.Success, accepted!.errorCode);
|
|
Assert.Equal(instance.photonRoomId, accepted.roomInstance!.photonRoomId);
|
|
var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null);
|
|
Assert.Equal(JoinStatus.Success, joined.errorCode);
|
|
Assert.Equal(instance.roomInstanceId,
|
|
(await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance);
|
|
|
|
var forged = new Message
|
|
{
|
|
FromPlayerId = StrangerId,
|
|
ToPlayerId = StrangerId,
|
|
Type = MessageType.GameInvite,
|
|
Data = instance.roomInstanceId.ToString(),
|
|
RoomId = RoomId
|
|
};
|
|
db.Messages.Add(forged);
|
|
await db.SaveChangesAsync();
|
|
var forgedJoiner = new MatchmakingAPI(db);
|
|
SetUser(forgedJoiner, StrangerId);
|
|
var forgedDenied = await forgedJoiner.GoToRoomInstance(instance.roomInstanceId, null);
|
|
Assert.Equal(JoinStatus.RoomInstanceIsPrivate, forgedDenied.errorCode);
|
|
|
|
db.Messages.Remove(invite);
|
|
db.Messages.Remove(forged);
|
|
await db.SaveChangesAsync();
|
|
var nextPlayer = new MatchmakingAPI(db);
|
|
SetUser(nextPlayer, StrangerId);
|
|
var denied = await nextPlayer.GoToRoomInstance(instance.roomInstanceId, null);
|
|
Assert.Equal(JoinStatus.RoomInstanceIsPrivate, denied.errorCode);
|
|
|
|
var uninvited = new MatchmakingAPI(db);
|
|
SetUser(uninvited, StrangerId);
|
|
var unauthorizedInvite = await uninvited.InvitePlayer(InviteeId, instance.roomInstanceId);
|
|
Assert.False(unauthorizedInvite.success);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The room half of an invite. Answering a game invite starts with fetching the room it
|
|
/// names (`GET rooms/rooms/{id}?include=5421...` and `rooms/rooms/bulk?id=` for its card),
|
|
/// and a private room used to answer 404 / [] to everyone but its owner - so the one player
|
|
/// who had just been invited could not see the room they were invited to, and the prompt had
|
|
/// nothing to join from. The invite is the access it grants and nothing more: a stranger
|
|
/// still gets 404, and a sender who has left the room stops granting it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task InviteAlsoMakesThePrivateRoomFetchableForTheInviteeOnly()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
|
|
// A dorm is the shape the live report has: the friend invited from their own dorm,
|
|
// and that is the private room a stranger's fetch answers 404 for.
|
|
var room = await db.Rooms.SingleAsync(x => x.RoomId == RoomId);
|
|
room.IsDorm = true;
|
|
await db.SaveChangesAsync();
|
|
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "private-instance"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = CreatorId,
|
|
roomInstance = instance.roomInstanceId,
|
|
isOnline = true
|
|
});
|
|
db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true });
|
|
db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
var inviter = new MatchmakingAPI(db);
|
|
SetUser(inviter, CreatorId);
|
|
Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success);
|
|
|
|
// The room itself, through the same handler the client's fetch lands on.
|
|
var inviteeRooms = new RoomServerAPI(db);
|
|
SetUser(inviteeRooms, InviteeId);
|
|
Assert.IsType<OkObjectResult>((await inviteeRooms.GetRoomById(RoomId)).Result);
|
|
|
|
var strangerRooms = new RoomServerAPI(db);
|
|
SetUser(strangerRooms, StrangerId);
|
|
Assert.IsType<NotFoundResult>((await strangerRooms.GetRoomById(RoomId)).Result);
|
|
|
|
// The card the prompt renders from is the same rule, so the bulk answer carries the
|
|
// room for the invitee and stays empty for everyone else.
|
|
var inviteeBulk = await inviteeRooms.GetBulkRooms(null, new List<long> { RoomId });
|
|
Assert.Single(inviteeBulk!);
|
|
var strangerBulk = await strangerRooms.GetBulkRooms(null, new List<long> { RoomId });
|
|
Assert.Empty(strangerBulk!);
|
|
|
|
// The inviter leaving takes the invite's worth with it: there is no instance left to
|
|
// be placed in, so the room goes back to being invisible.
|
|
(await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = null;
|
|
await db.SaveChangesAsync();
|
|
Assert.IsType<NotFoundResult>((await inviteeRooms.GetRoomById(RoomId)).Result);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task AcceptedInviteKeepsDormLoadableAfterMessageDeletion(bool chatInvite)
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var room = await db.Rooms.SingleAsync();
|
|
room.IsDorm = true;
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId, subRoomId = 1, isPrivate = true,
|
|
maxCapacity = 8, photonRoomId = "invite-room-load"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
var inviter = new MatchmakingAPI(db);
|
|
SetUser(inviter, CreatorId);
|
|
Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success);
|
|
var invite = await db.Messages.SingleAsync();
|
|
var accept = new MatchmakingAPI(db);
|
|
SetUser(accept, InviteeId);
|
|
if (chatInvite)
|
|
{
|
|
db.Chats.Add(new ChatThread
|
|
{
|
|
chatThreadId = 70, chatThreadName = "",
|
|
playerIds = $"[{InviteeId},{CreatorId}]",
|
|
messages = JsonSerializer.Serialize(new[] { new ChatMessage
|
|
{
|
|
chatMessageId = 2, chatThreadeId = 70, senderPlayerId = CreatorId,
|
|
timeSent = DateTime.UtcNow,
|
|
contents = "{\"Type\":1,\"Version\":1,\"Data\":\"Party invite\"}"
|
|
} })
|
|
});
|
|
await db.SaveChangesAsync();
|
|
Assert.Equal(JoinStatus.Success, (await accept.MatchmakeChatInvite(70, 2, null))!.errorCode);
|
|
}
|
|
else
|
|
Assert.Equal(JoinStatus.Success, (await accept.AcceptInviteByMessageId(invite.Id, null))!.errorCode);
|
|
|
|
// Inbox cleanup and the host leaving must not undo an accepted room admission.
|
|
db.Messages.Remove(invite);
|
|
(await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = null;
|
|
await db.SaveChangesAsync();
|
|
var rooms = new RoomServerAPI(db);
|
|
SetUser(rooms, InviteeId);
|
|
var loaded = Assert.IsType<OkObjectResult>((await rooms.GetRoomById(RoomId)).Result);
|
|
Assert.NotEmpty(Assert.IsType<FullRoom>(loaded.Value).SubRooms);
|
|
Assert.Single((await rooms.GetBulkRooms(null, new List<long> { RoomId }))!);
|
|
SetUser(rooms, StrangerId);
|
|
Assert.IsType<NotFoundResult>((await rooms.GetRoomById(RoomId)).Result);
|
|
|
|
var presence = await db.Presences.SingleAsync(x => x.playerId == InviteeId);
|
|
presence.isOnline = false;
|
|
await db.SaveChangesAsync();
|
|
SetUser(rooms, InviteeId);
|
|
Assert.IsType<NotFoundResult>((await rooms.GetRoomById(RoomId)).Result);
|
|
presence.isOnline = true;
|
|
presence.roomInstance = null;
|
|
await db.SaveChangesAsync();
|
|
Assert.IsType<NotFoundResult>((await rooms.GetRoomById(RoomId)).Result);
|
|
}
|
|
finally { RestoreFactories(previousFactories); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExistingMemberCanForwardPrivateRoomInviteAndTheInviteeCanJoin()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "forwarded-private-instance"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = StrangerId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
db.Messages.Add(new Message
|
|
{
|
|
ToPlayerId = InviteeId,
|
|
FromPlayerId = CreatorId,
|
|
Type = MessageType.GameInvite,
|
|
Data = instance.roomInstanceId.ToString(),
|
|
RoomId = RoomId
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var inviter = new MatchmakingAPI(db);
|
|
SetUser(inviter, InviteeId);
|
|
Assert.True((await inviter.InvitePlayer(StrangerId, instance.roomInstanceId)).success);
|
|
var forwardedInvite = await db.Messages.SingleAsync(x => x.ToPlayerId == StrangerId);
|
|
Assert.Equal(InviteeId, forwardedInvite.FromPlayerId);
|
|
|
|
var joiner = new MatchmakingAPI(db);
|
|
SetUser(joiner, StrangerId);
|
|
var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null);
|
|
Assert.Equal(JoinStatus.Success, joined.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayerInPrivateInstanceOfPublicRoomCanInvitePartyMemberToJoin()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Public);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "player-private-instance"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = StrangerId,
|
|
roomInstance = instance.roomInstanceId,
|
|
isOnline = true
|
|
});
|
|
db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
var inviter = new MatchmakingAPI(db);
|
|
SetUser(inviter, StrangerId);
|
|
Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success);
|
|
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(StrangerId, invite.FromPlayerId);
|
|
Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite));
|
|
Assert.Equal(RoomId, invite.RoomId);
|
|
|
|
var joiner = new MatchmakingAPI(db);
|
|
SetUser(joiner, InviteeId);
|
|
var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null);
|
|
Assert.Equal(JoinStatus.Success, joined.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CoOwnerRoleInviteCanBeAcceptedByInviteeButNotForgedByAnotherPlayer()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db);
|
|
var api = new RoomServerAPI(db);
|
|
SetUser(api, CreatorId);
|
|
SetFormBody(api, "role=30");
|
|
var inviteResult = await api.InvitePlayerToRole(RoomId, InviteeId);
|
|
Assert.True(inviteResult!.success);
|
|
|
|
var inviteMessage = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.RoomCoOwnerInvited, inviteMessage.Type);
|
|
Assert.Equal(RoomId, inviteMessage.RoomId);
|
|
Assert.Equal(RoleType.None,
|
|
(await db.RoomRoles.SingleAsync(x => x.RoomId == RoomId && x.AccountId == InviteeId)).Role);
|
|
|
|
var forged = new RoomServerAPI(db);
|
|
SetUser(forged, StrangerId);
|
|
SetFormBody(forged, "role=30");
|
|
Assert.False((await forged.SetPlayerRole(RoomId, InviteeId))!.success);
|
|
|
|
var acceptance = new RoomServerAPI(db);
|
|
SetUser(acceptance, InviteeId);
|
|
SetFormBody(acceptance, "");
|
|
Assert.True((await acceptance.SetPlayerRole(RoomId, InviteeId))!.success);
|
|
Assert.Equal(RoleType.CoOwner,
|
|
(await db.RoomRoles.SingleAsync(x => x.RoomId == RoomId && x.AccountId == InviteeId)).Role);
|
|
Assert.Contains(await db.Messages.ToListAsync(), x => x.ToPlayerId == InviteeId && x.Type == MessageType.RoomCoOwnerAdded);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClubInviteAddsConfiguredMembershipAndRequiresCoownerAuthority()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await ClubStore.SaveAsync(db, new List<ClubRecord>
|
|
{
|
|
new() { clubId = ClubId, ownerPlayerId = CreatorId, name = "Test club" }
|
|
});
|
|
|
|
var unauthorized = new ClubsAPI(db);
|
|
SetUser(unauthorized, InviteeId);
|
|
SetFormBody(unauthorized, $"accountId={StrangerId}&membershipType=10");
|
|
var denied = await unauthorized.InviteMember(ClubId);
|
|
Assert.IsType<ObjectResult>(denied);
|
|
Assert.Equal(StatusCodes.Status403Forbidden, ((ObjectResult)denied).StatusCode);
|
|
|
|
var api = new ClubsAPI(db);
|
|
SetUser(api, CreatorId);
|
|
SetFormBody(api, $"accountId={InviteeId}&membershipType=20");
|
|
var result = await api.InviteMember(ClubId);
|
|
Assert.IsType<OkObjectResult>(result);
|
|
|
|
var updated = await ClubStore.GetAsync(db, ClubId);
|
|
Assert.Equal(ClubMembershipTypes.PendingInvited, ClubStore.GetMembershipType(updated, InviteeId));
|
|
Assert.Equal(1, ClubStore.GetMemberCount(updated));
|
|
var pendingMember = Assert.Single(ClubStore.GetMembers(updated), member => member.AccountId == InviteeId);
|
|
Assert.Equal(CreatorId, pendingMember.InviterAccountId);
|
|
Assert.Equal(ClubMembershipTypes.PendingInvited, pendingMember.MembershipType);
|
|
var pendingWireMember = Assert.IsType<Dictionary<string, object?>>(
|
|
ClubStore.BuildMember(pendingMember, updated, ProfileFor(InviteeId)));
|
|
Assert.Equal(0, pendingWireMember["membershipStatus"]);
|
|
Assert.Equal(CreatorId, pendingWireMember["inviterAccountId"]);
|
|
|
|
var message = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.ClubModeratorInvited, message.Type);
|
|
Assert.Equal(ClubId.ToString(), message.Data);
|
|
Assert.Equal(ClubMembershipTypes.Moderator.ToString(), message.Details);
|
|
|
|
// Only the invitee can activate the stored tier; before acceptance the
|
|
// pending row isn't counted as a member or given the invited role.
|
|
var forgedAcceptance = new ClubsAPI(db);
|
|
SetUser(forgedAcceptance, StrangerId);
|
|
Assert.IsType<BadRequestObjectResult>(await forgedAcceptance.AcceptClubInvitation(ClubId));
|
|
|
|
var acceptance = new ClubsAPI(db);
|
|
SetUser(acceptance, InviteeId);
|
|
var accepted = Assert.IsType<OkObjectResult>(await acceptance.AcceptClubInvitation(ClubId));
|
|
var acceptedClub = await ClubStore.GetAsync(db, ClubId);
|
|
Assert.Equal(ClubMembershipTypes.Moderator, ClubStore.GetMembershipType(acceptedClub, InviteeId));
|
|
Assert.Equal(2, ClubStore.GetMemberCount(acceptedClub));
|
|
var activeMember = Assert.Single(ClubStore.GetMembers(acceptedClub), member => member.AccountId == InviteeId);
|
|
var activeWireMember = Assert.IsType<Dictionary<string, object?>>(
|
|
ClubStore.BuildMember(activeMember, acceptedClub, ProfileFor(InviteeId)));
|
|
Assert.Equal(1, activeWireMember["membershipStatus"]);
|
|
Assert.Contains("Moderator", JsonSerializer.Serialize(accepted.Value));
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
// ---- The spellings a 20230831 client can post ---------------------------------
|
|
|
|
/// <summary>
|
|
/// The invite surface is pinned by shape because a 404 is what the player reads as
|
|
/// `Invite Failed!`: the bare form spelling the September client provably posts (its own
|
|
/// log shows `POST /matchmaking/invite` with a form body on an Invite press), the path
|
|
/// spelling beside it for a build that puts the target in the path, and the offline variant
|
|
/// beside the api routes.
|
|
///
|
|
/// The client's `matchmake/invite/{0}` is NOT a send spelling. It sits in the contiguous
|
|
/// `matchmake/*` JOIN family, so it serves the accept-by-invite-id spelling
|
|
/// (<see cref="MatchmakingAPI.AcceptInviteByMessageId"/>), beside the chat-party
|
|
/// acceptance `matchmake/chatinvite/{threadId}/{messageId}`. Treating the path as a send
|
|
/// would invite whatever account happened to hold the same number as an invite's own
|
|
/// message id.
|
|
/// </summary>
|
|
[Fact]
|
|
public void InviteIsServedUnderEverySpellingTheClientsPost()
|
|
{
|
|
var sends = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.Invite))!
|
|
.GetCustomAttributes<HttpPostAttribute>()
|
|
.Select(attribute => attribute.Template!)
|
|
.ToArray();
|
|
|
|
Assert.Contains("invite", sends);
|
|
Assert.Contains("invite/{playerId:long}", sends);
|
|
Assert.DoesNotContain(sends, route => route.StartsWith("matchmake/", StringComparison.Ordinal));
|
|
|
|
Assert.Equal("matchmake/invite/{inviteId:long}",
|
|
Assert.Single(typeof(MatchmakingAPI)
|
|
.GetMethod(nameof(MatchmakingAPI.AcceptInviteByMessageId))!
|
|
.GetCustomAttributes<HttpPostAttribute>()).Template);
|
|
Assert.Equal("matchmake/chatinvite/{threadId:long}/{messageId:long}",
|
|
Assert.Single(typeof(MatchmakingAPI)
|
|
.GetMethod(nameof(MatchmakingAPI.MatchmakeChatInvite))!
|
|
.GetCustomAttributes<HttpPostAttribute>()).Template);
|
|
|
|
var offline = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.SendOfflineInvite))!;
|
|
Assert.Equal("~/api/offlineinvite/v1/send",
|
|
Assert.Single(offline.GetCustomAttributes<HttpPostAttribute>()).Template);
|
|
|
|
// The party-invite link lifetime rides the settings routes, as a bare single-key object:
|
|
// the api host the sender's client reads, and the chat host the receiver's card fetches
|
|
// when a party invite message arrives.
|
|
var partyInviteRoutes = typeof(PlayerSettingsAPI)
|
|
.GetMethod(nameof(PlayerSettingsAPI.GetPartyInviteSettings))!
|
|
.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(attribute => attribute.Template!)
|
|
.ToArray();
|
|
Assert.Contains("partyinvite", partyInviteRoutes);
|
|
Assert.Contains("~/chat/settings/partyinvite", partyInviteRoutes);
|
|
Assert.Contains("api/settings", typeof(PlayerSettingsAPI)
|
|
.GetCustomAttributes<RouteAttribute>().Select(attribute => attribute.Template));
|
|
}
|
|
|
|
[Fact]
|
|
public void PartyInviteSettingsAnswerTheBareSingleKeyObject()
|
|
{
|
|
using var db = new RecDBContext(TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
|
|
|
|
var result = Assert.IsType<OkObjectResult>(new PlayerSettingsAPI(db).GetPartyInviteSettings());
|
|
|
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
|
|
var property = Assert.Single(json.RootElement.EnumerateObject());
|
|
Assert.Equal("InviteLinkLifetimeInMinutes", property.Name);
|
|
Assert.Equal(
|
|
JsonValueKind.Number,
|
|
property.Value.ValueKind);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The path spelling carries no instance at all, so the invite has to mean "the instance I am
|
|
/// standing in" - the caller's own presence row, which is the same row the invite insists the
|
|
/// inviter be live in.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PathShapedInviteWithoutAnInstanceInvitesIntoTheCallersCurrentInstance()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "path-shaped-invite"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new MatchmakingAPI(db);
|
|
SetUser(api, CreatorId);
|
|
api.Request.RouteValues = new RouteValueDictionary { ["playerId"] = InviteeId.ToString() };
|
|
|
|
var result = await api.Invite();
|
|
|
|
Assert.True(result!.success);
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.GameInviteV2, invite.Type);
|
|
Assert.Equal(CreatorId, invite.FromPlayerId);
|
|
Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite));
|
|
Assert.Equal(RoomId, invite.RoomId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// No instance in the request AND none on the caller is the one case worth refusing loudly:
|
|
/// the invite would otherwise be stored pointing at nothing, and the recipient's client would
|
|
/// read a join prompt for a room that does not exist.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task InviteWithNoInstanceAndNoPresenceIsRefusedWithoutStoringAnything()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var api = new MatchmakingAPI(db);
|
|
SetUser(api, CreatorId);
|
|
api.Request.RouteValues = new RouteValueDictionary { ["playerId"] = InviteeId.ToString() };
|
|
|
|
var result = await api.Invite();
|
|
|
|
Assert.False(result!.success);
|
|
Assert.Empty(await db.Messages.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// An offline invite is the same stored game invite: the target simply has no hub frame to
|
|
/// receive, and reads the stored message when they next sign in. Nothing about the invite is
|
|
/// weakened for them - the instance and the room still name where to go.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OfflineInviteStoresTheSameGameInviteForATargetWithNoPresence()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = false,
|
|
maxCapacity = 8,
|
|
photonRoomId = "offline-invite"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = CreatorId,
|
|
roomInstance = instance.roomInstanceId,
|
|
isOnline = true
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new MatchmakingAPI(db);
|
|
SetUser(api, CreatorId);
|
|
SetFormBody(api, $"invitedPlayerId={InviteeId}&roomInstanceId={instance.roomInstanceId}");
|
|
|
|
var result = await api.SendOfflineInvite();
|
|
|
|
Assert.True(result.success);
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.GameInviteV2, invite.Type);
|
|
Assert.Equal(CreatorId, invite.FromPlayerId);
|
|
Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite));
|
|
Assert.Equal(RoomId, invite.RoomId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
// ---- Accepting ---------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The client's accept spelling for a game invite is its own `matchmake/invite/{0}`, the id
|
|
/// being the invite message the hub pushed. The invite authorizes that exact instance for
|
|
/// its recipient - a private instance is what an invite exists to get into - and the move
|
|
/// is the same presence write a join performs, so the client's follow-up sees the recipient
|
|
/// standing in the sender's instance.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AcceptingAStoredGameInviteMovesTheInviteeIntoTheInviterInstance()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "accepted-game-invite"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
db.Messages.Add(new Message
|
|
{
|
|
FromPlayerId = CreatorId,
|
|
ToPlayerId = InviteeId,
|
|
Type = MessageType.GameInvite,
|
|
Data = instance.roomInstanceId.ToString(),
|
|
RoomId = RoomId
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
|
|
var accept = new MatchmakingAPI(db);
|
|
SetUser(accept, InviteeId);
|
|
var result = await accept.AcceptInviteByMessageId(invite.Id, null);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(JoinStatus.Success, result!.errorCode);
|
|
Assert.Equal(instance.roomInstanceId, result.roomInstance?.roomInstanceId);
|
|
var presence = await db.Presences.SingleAsync(x => x.playerId == InviteeId);
|
|
Assert.Equal(instance.roomInstanceId, presence.roomInstance);
|
|
Assert.True(presence.isOnline);
|
|
Assert.Equal(JoinStatus.Success, presence.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every way a game invite can be stale or forged is answered with the code the client
|
|
/// already has for it, rather than with a status it reads as a network failure: someone
|
|
/// else's invite is expired, a note the caller wrote themselves is not an invite, a sender
|
|
/// with no live session is not online, a sender who moved on makes the invite stale, and an
|
|
/// instance that is gone names nothing to join.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AcceptingAGameInviteRefusesStaleForgedAndOfflineInvitesByName()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "refused-game-invite"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
Message Store(long to, long from, string data)
|
|
{
|
|
var message = new Message
|
|
{
|
|
FromPlayerId = from,
|
|
ToPlayerId = to,
|
|
Type = MessageType.GameInvite,
|
|
Data = data,
|
|
RoomId = RoomId
|
|
};
|
|
db.Messages.Add(message);
|
|
db.SaveChanges();
|
|
return message;
|
|
}
|
|
|
|
async Task<MatchResponse?> AcceptAs(long caller, long inviteId)
|
|
{
|
|
var api = new MatchmakingAPI(db);
|
|
SetUser(api, caller);
|
|
return await api.AcceptInviteByMessageId(inviteId, null);
|
|
}
|
|
|
|
// Addressed to someone else: the caller has no such invite.
|
|
var foreign = Store(StrangerId, CreatorId, instance.roomInstanceId.ToString());
|
|
var foreignResult = await AcceptAs(InviteeId, foreign.Id);
|
|
Assert.Equal(JoinStatus.RoomInviteExpired, foreignResult!.errorCode);
|
|
Assert.Null(foreignResult.roomInstance);
|
|
|
|
// Written by the caller themselves.
|
|
var selfSent = Store(InviteeId, InviteeId, instance.roomInstanceId.ToString());
|
|
Assert.Equal(JoinStatus.ChatMessageNotAnInvite, (await AcceptAs(InviteeId, selfSent.Id))!.errorCode);
|
|
|
|
// The sender has no live session to be standing in the instance.
|
|
var fromOffline = Store(InviteeId, StrangerId, instance.roomInstanceId.ToString());
|
|
Assert.Equal(JoinStatus.PlayerNotOnline, (await AcceptAs(InviteeId, fromOffline.Id))!.errorCode);
|
|
|
|
// The sender moved to another instance, so the invite no longer names where they are.
|
|
var elsewhere = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "somewhere-else"
|
|
};
|
|
db.RoomInstances.Add(elsewhere);
|
|
await db.SaveChangesAsync();
|
|
(await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = elsewhere.roomInstanceId;
|
|
await db.SaveChangesAsync();
|
|
var moved = Store(InviteeId, CreatorId, instance.roomInstanceId.ToString());
|
|
Assert.Equal(JoinStatus.RoomInviteExpired, (await AcceptAs(InviteeId, moved.Id))!.errorCode);
|
|
|
|
// The named instance is no longer live.
|
|
(await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = 999_999;
|
|
await db.SaveChangesAsync();
|
|
var gone = Store(InviteeId, CreatorId, "999999");
|
|
Assert.Equal(JoinStatus.RoomIsNotActive, (await AcceptAs(InviteeId, gone.Id))!.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The chat-party acceptance the client spells `matchmake/chatinvite/{threadId}/{messageId}`:
|
|
/// when the message is stored, its sender is the inviter and their live instance is the
|
|
/// destination - the same stored thread the chat surface serves, read here for who wrote
|
|
/// the invite rather than for what it says.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AcceptingAChatInviteResolvesTheInviterFromTheStoredMessage()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "accepted-chat-invite"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
const long ThreadId = 70;
|
|
db.Chats.Add(new ChatThread
|
|
{
|
|
chatThreadId = ThreadId,
|
|
chatThreadName = "",
|
|
playerIds = $"[{InviteeId},{CreatorId}]",
|
|
messages = JsonSerializer.Serialize(new List<ChatMessage>
|
|
{
|
|
new()
|
|
{
|
|
chatMessageId = 2,
|
|
chatThreadeId = ThreadId,
|
|
senderPlayerId = CreatorId,
|
|
timeSent = DateTime.UtcNow,
|
|
contents = "\"come hang out\""
|
|
}
|
|
})
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var accept = new MatchmakingAPI(db);
|
|
SetUser(accept, InviteeId);
|
|
var result = await accept.MatchmakeChatInvite(ThreadId, 2, null);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(JoinStatus.Success, result!.errorCode);
|
|
Assert.Equal(instance.roomInstanceId, result.roomInstance?.roomInstanceId);
|
|
Assert.Equal(instance.roomInstanceId,
|
|
(await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A message id this server never stored falls back to the thread's single other member -
|
|
/// the only person a party invite in that conversation could be from. A caller who is not
|
|
/// in the thread is refused by name, and a stored message that is too old (or written by
|
|
/// the caller) is refused with the code the client already has for it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ChatInviteAcceptFallsBackToTheThreadsOtherMemberAndRefusesTheRest()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedRoomAsync(db, Accessibility.Private);
|
|
var instance = new RoomInstance
|
|
{
|
|
roomId = RoomId,
|
|
subRoomId = 1,
|
|
isPrivate = true,
|
|
maxCapacity = 8,
|
|
photonRoomId = "chat-invite-fallback"
|
|
};
|
|
db.RoomInstances.Add(instance);
|
|
db.Presences.AddRange(
|
|
new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true },
|
|
new RealPresence { playerId = InviteeId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
|
|
const long ThreadId = 71;
|
|
const long ExpiredThreadId = 72;
|
|
const long ForeignThreadId = 73;
|
|
db.Chats.AddRange(
|
|
new ChatThread
|
|
{
|
|
chatThreadId = ThreadId,
|
|
chatThreadName = "",
|
|
playerIds = $"[{InviteeId},{CreatorId}]",
|
|
messages = "[]"
|
|
},
|
|
new ChatThread
|
|
{
|
|
chatThreadId = ExpiredThreadId,
|
|
chatThreadName = "",
|
|
playerIds = $"[{InviteeId},{CreatorId}]",
|
|
messages = JsonSerializer.Serialize(new List<ChatMessage>
|
|
{
|
|
new()
|
|
{
|
|
chatMessageId = 5,
|
|
chatThreadeId = ExpiredThreadId,
|
|
senderPlayerId = CreatorId,
|
|
timeSent = DateTime.UtcNow.AddMinutes(-(PlayerSettingsAPI.InviteLinkLifetimeInMinutes + 5)),
|
|
contents = "\"too old\""
|
|
}
|
|
})
|
|
},
|
|
new ChatThread
|
|
{
|
|
chatThreadId = ForeignThreadId,
|
|
chatThreadName = "",
|
|
playerIds = $"[{CreatorId},{StrangerId}]",
|
|
messages = "[]"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
// Not stored, exactly one other member: they are the inviter.
|
|
var fallback = new MatchmakingAPI(db);
|
|
SetUser(fallback, InviteeId);
|
|
var fallbackResult = await fallback.MatchmakeChatInvite(ThreadId, 77, null);
|
|
Assert.Equal(JoinStatus.Success, fallbackResult!.errorCode);
|
|
Assert.Equal(instance.roomInstanceId,
|
|
(await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance);
|
|
|
|
var expired = new MatchmakingAPI(db);
|
|
SetUser(expired, InviteeId);
|
|
Assert.Equal(JoinStatus.RoomInviteExpired,
|
|
(await expired.MatchmakeChatInvite(ExpiredThreadId, 5, null))!.errorCode);
|
|
|
|
var foreign = new MatchmakingAPI(db);
|
|
SetUser(foreign, InviteeId);
|
|
Assert.Equal(JoinStatus.ChatPartyInviteNotFound,
|
|
(await foreign.MatchmakeChatInvite(ForeignThreadId, 1, null))!.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FriendInviteCreatesReciprocalPendingRowsAndDurableMessage()
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var sender = new RelationshipsV2(db);
|
|
SetUser(sender, CreatorId);
|
|
|
|
Assert.IsType<OkObjectResult>(await sender.SendFriendRequest(InviteeId));
|
|
|
|
Assert.Equal(RelationshipType.FriendRequestSent,
|
|
(await db.Relationships.SingleAsync(x => x.AccountId == CreatorId && x.PlayerId == InviteeId)).RelationshipType);
|
|
Assert.Equal(RelationshipType.FriendRequestReceived,
|
|
(await db.Relationships.SingleAsync(x => x.AccountId == InviteeId && x.PlayerId == CreatorId)).RelationshipType);
|
|
var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId);
|
|
Assert.Equal(MessageType.FriendInvite, invite.Type);
|
|
Assert.Equal(CreatorId, invite.FromPlayerId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheBroadcasterKeepsTheirOwnInstanceAndAViewerIsMatchedElsewhere()
|
|
{
|
|
// The instance a broadcaster registers is their own room. A viewer who may join the
|
|
// event is still not placed in it - a broadcast advertises the session, it does not
|
|
// open it - so matchmaking hands them the event's ordinary instance instead.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = true;
|
|
|
|
var ordinary = new RoomInstance { roomInstanceId = 500, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
db.RoomInstances.AddRange(ordinary, broadcast);
|
|
await db.SaveChangesAsync();
|
|
|
|
ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId;
|
|
await db.SaveChangesAsync();
|
|
|
|
var host = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId);
|
|
Assert.Equal(JoinStatus.Success, host.errorCode);
|
|
Assert.Equal(broadcast.roomInstanceId, host.roomInstance?.roomInstanceId);
|
|
|
|
var viewer = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
Assert.Equal(JoinStatus.Success, viewer.errorCode);
|
|
Assert.NotEqual(broadcast.roomInstanceId, viewer.roomInstance?.roomInstanceId);
|
|
Assert.Equal(ordinary.roomInstanceId, viewer.roomInstance?.roomInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvitedViewerIsStillNotPlacedInTheBroadcastersInstance()
|
|
{
|
|
// An invitation grants the event, not a place in the broadcaster's own room: an invited
|
|
// viewer is matched into an ordinary instance exactly like an uninvited one.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = true;
|
|
db.PlayerEventResponses.Add(new PlayerEventResponse
|
|
{
|
|
PlayerEventId = EventId, PlayerId = InviteeId, Type = EventResponses.Yes
|
|
});
|
|
|
|
var ordinary = new RoomInstance { roomInstanceId = 500, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
db.RoomInstances.AddRange(ordinary, broadcast);
|
|
await db.SaveChangesAsync();
|
|
|
|
ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId;
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.Success, match.errorCode);
|
|
Assert.Equal(ordinary.roomInstanceId, match.roomInstance?.roomInstanceId);
|
|
Assert.NotEqual(broadcast.roomInstanceId, match.roomInstance?.roomInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AViewerGetsTheirOwnInstanceWhenTheBroadcastIsTheOnlyOne()
|
|
{
|
|
// With nothing else to share, the viewer is given a fresh instance of the event's room -
|
|
// the broadcast instance is never reused - and it is public, so the next viewer lands
|
|
// beside them instead of causing a third instance.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = true;
|
|
db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true });
|
|
|
|
var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
db.RoomInstances.Add(broadcast);
|
|
await db.SaveChangesAsync();
|
|
|
|
ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId;
|
|
await db.SaveChangesAsync();
|
|
|
|
var first = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.Success, first.errorCode);
|
|
Assert.NotNull(first.roomInstance);
|
|
Assert.NotEqual(broadcast.roomInstanceId, first.roomInstance!.roomInstanceId);
|
|
Assert.Equal(EventId, first.roomInstance.eventId);
|
|
Assert.False(first.roomInstance.isPrivate);
|
|
|
|
var second = await MatchmakingUtils.MatchmakeEvent(StrangerId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.Success, second.errorCode);
|
|
Assert.Equal(first.roomInstance.roomInstanceId, second.roomInstance?.roomInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AViewerIsNotPlacedWithTheBroadcasterWhenTheRegisteredInstanceIsStale()
|
|
{
|
|
// Re-entering the event hands the broadcaster a fresh instance and nothing re-registers
|
|
// it, so the id the event still carries can name a room nobody is in. The instance the
|
|
// broadcaster is actually standing in is off-limits for the same reason the registered
|
|
// one is - otherwise the protection lapses the first time they walk back in.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = true;
|
|
|
|
var registered = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
var standingIn = new RoomInstance { roomInstanceId = 502, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
db.RoomInstances.AddRange(registered, standingIn);
|
|
await db.SaveChangesAsync();
|
|
|
|
ev.BroadcastingRoomInstanceId = registered.roomInstanceId;
|
|
// The broadcaster has since re-entered the event: their presence names 502 while the
|
|
// event still advertises the instance they registered, 501.
|
|
db.Presences.Single(x => x.playerId == CreatorId).roomInstance = standingIn.roomInstanceId;
|
|
await db.SaveChangesAsync();
|
|
|
|
var viewer = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.Success, viewer.errorCode);
|
|
Assert.NotNull(viewer.roomInstance);
|
|
Assert.NotEqual(registered.roomInstanceId, viewer.roomInstance!.roomInstanceId);
|
|
Assert.NotEqual(standingIn.roomInstanceId, viewer.roomInstance.roomInstanceId);
|
|
Assert.Equal(EventId, viewer.roomInstance.eventId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatorCanJoinOwnEventLongBeforeItStarts()
|
|
{
|
|
// The host walks into their own event whenever they want, so the room they are
|
|
// advertising is already stood up (and they can be standing in it) hours early.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db);
|
|
ev.StartTime = DateTime.UtcNow.AddHours(6);
|
|
ev.EndTime = DateTime.UtcNow.AddHours(7);
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.Success, match.errorCode);
|
|
Assert.Equal(EventId, match.roomInstance?.eventId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonCreatorIsStillHeldToTheEventStartTime()
|
|
{
|
|
// The early gate is the host's alone. An invited guest who shows up before the
|
|
// start time is turned away rather than dropped into an event that has not begun.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.StartTime = DateTime.UtcNow.AddHours(6);
|
|
ev.EndTime = DateTime.UtcNow.AddHours(7);
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.EventNotStarted, match.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatorCannotJoinAnEventThatHasAlreadyFinished()
|
|
{
|
|
// The exemption opens the door early, not indefinitely: once the window closes the
|
|
// event is closed to its own creator too.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db);
|
|
ev.StartTime = DateTime.UtcNow.AddHours(-2);
|
|
ev.EndTime = DateTime.UtcNow.AddHours(-1);
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId);
|
|
|
|
Assert.Equal(JoinStatus.EventAlreadyFinished, match.errorCode);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false, 3)]
|
|
[InlineData(true, 6)]
|
|
public async Task EventInstanceWireTypeMatchesMultiInstanceSetting(bool multiInstance, int wireType)
|
|
{
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = multiInstance;
|
|
db.RoomInstances.Add(new RoomInstance
|
|
{
|
|
roomInstanceId = 501, roomId = RoomId, subRoomId = 1,
|
|
eventId = EventId, maxCapacity = 8,
|
|
roomInstanceType = RadicadeServer.RecNet.Enums.Rooms.RoomInstanceType.Event
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
// Reusing a room created before multi-instance was enabled must repair its type.
|
|
var existing = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId);
|
|
Assert.Equal(JoinStatus.Success, existing.errorCode);
|
|
Assert.Equal(501, existing.roomInstance!.roomInstanceId);
|
|
Assert.Equal(wireType, (int)existing.roomInstance.roomInstanceType);
|
|
db.ChangeTracker.Clear();
|
|
Assert.Equal(wireType, (int)(await db.RoomInstances.SingleAsync()).roomInstanceType);
|
|
|
|
// Fresh private event instances need the same type; privacy is a separate flag.
|
|
var created = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId, JoinMode.PrivateNewInstance);
|
|
Assert.Equal(JoinStatus.Success, created.errorCode);
|
|
Assert.True(created.roomInstance!.isPrivate);
|
|
var json = System.Text.Json.JsonSerializer.SerializeToElement(created.roomInstance);
|
|
Assert.Equal(wireType, json.GetProperty("roomInstanceType").GetInt32());
|
|
}
|
|
finally { RestoreFactories(previousFactories); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task PrivateModeOnAnEventCreatesThePlayersOwnPrivateInstance(bool sourceStillOccupied)
|
|
{
|
|
// PRIVATE MODE on the event screen asks for the player's own copy of the event's
|
|
// room. It has to be created even while the event has a shared instance and a live
|
|
// broadcast instance to hand out - and it must be neither of those, or pressing the
|
|
// button would move the player into somebody else's room.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
ev.IsMultiInstance = true;
|
|
|
|
var shared = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
var broadcast = new RoomInstance { roomInstanceId = 502, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 };
|
|
db.RoomInstances.AddRange(shared, broadcast);
|
|
await db.SaveChangesAsync();
|
|
ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId;
|
|
if (sourceStillOccupied)
|
|
{
|
|
var presenter = await db.Presences.SingleAsync(p => p.playerId == InviteeId);
|
|
presenter.isOnline = true;
|
|
presenter.roomInstance = broadcast.roomInstanceId;
|
|
}
|
|
await db.SaveChangesAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(
|
|
CreatorId, EventId, JoinMode.PrivateNewInstance);
|
|
|
|
Assert.Equal(JoinStatus.Success, match.errorCode);
|
|
Assert.NotNull(match.roomInstance);
|
|
Assert.NotEqual(shared.roomInstanceId, match.roomInstance.roomInstanceId);
|
|
Assert.NotEqual(broadcast.roomInstanceId, match.roomInstance.roomInstanceId);
|
|
Assert.Equal(EventId, match.roomInstance.eventId);
|
|
// Event rooms stay event instances; `isPrivate` is what makes this one the
|
|
// player's own.
|
|
Assert.True(match.roomInstance.isPrivate);
|
|
// Matchmaking joined the player through its own context, so read the presence back
|
|
// fresh rather than through this context's tracked copy.
|
|
db.ChangeTracker.Clear();
|
|
Assert.Equal(sourceStillOccupied ? (long?)broadcast.roomInstanceId : null,
|
|
(await db.PlayerEvents.SingleAsync(e => e.PlayerEventId == EventId)).BroadcastingRoomInstanceId);
|
|
Assert.Equal(match.roomInstance.roomInstanceId,
|
|
(await db.Presences.AsNoTracking().SingleAsync(x => x.playerId == CreatorId)).roomInstance);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EachPlayersPrivateModeGetsItsOwnInstance()
|
|
{
|
|
// Two people pressing PRIVATE MODE on the same event do not end up together: every
|
|
// request is a new room, so neither is handed the other's private instance.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
|
|
var host = await MatchmakingUtils.MatchmakeEvent(
|
|
CreatorId, EventId, JoinMode.PrivateNewInstance);
|
|
var guest = await MatchmakingUtils.MatchmakeEvent(
|
|
InviteeId, EventId, JoinMode.PrivateNewInstance);
|
|
|
|
Assert.Equal(JoinStatus.Success, host.errorCode);
|
|
Assert.Equal(JoinStatus.Success, guest.errorCode);
|
|
Assert.NotNull(host.roomInstance);
|
|
Assert.NotNull(guest.roomInstance);
|
|
Assert.True(host.roomInstance!.isPrivate);
|
|
Assert.True(guest.roomInstance!.isPrivate);
|
|
Assert.NotEqual(host.roomInstance.roomInstanceId, guest.roomInstance.roomInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PrivateModeLeavesNoInstanceBehindForSomeoneWhoMayNotBeInTheRoom()
|
|
{
|
|
// The event's room is private and this player is not the host, not a co-owner and has
|
|
// no invitation, so PRIVATE MODE is refused - refused before anything is created, so
|
|
// a refused press does not leave an empty private instance in the room.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db, Accessibility.Public, Accessibility.Private);
|
|
db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true });
|
|
await db.SaveChangesAsync();
|
|
var before = await db.RoomInstances.CountAsync();
|
|
|
|
var match = await MatchmakingUtils.MatchmakeEvent(
|
|
StrangerId, EventId, JoinMode.PrivateNewInstance);
|
|
|
|
Assert.Equal(JoinStatus.RoomIsPrivate, match.errorCode);
|
|
Assert.Equal(before, await db.RoomInstances.CountAsync());
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventRouteHonoursTheClientsPrivateMode()
|
|
{
|
|
// Both event routes take the same GoToRequest as every other go-to call, and the join
|
|
// mode on it is what the event screen's PRIVATE MODE button sends. Without it the
|
|
// route answered with a shared instance and the client waited on a private room that
|
|
// was never created.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db, Accessibility.Public, Accessibility.Public);
|
|
var controller = new MatchmakingAPI(db);
|
|
SetUser(controller, CreatorId);
|
|
|
|
var privateJoin = await controller.GoToEvent(EventId,
|
|
new GoToRequest { JoinMode = JoinMode.PrivateNewInstance });
|
|
|
|
Assert.Equal(JoinStatus.Success, privateJoin!.errorCode);
|
|
Assert.True(privateJoin.roomInstance!.isPrivate);
|
|
|
|
// And the ordinary join is still the shared one: the private instance above is
|
|
// never handed out by the plain lookup.
|
|
var ordinaryJoin = await controller.GoToEvent(EventId,
|
|
new GoToRequest { JoinMode = JoinMode.PublicMatchmaking });
|
|
|
|
Assert.Equal(JoinStatus.Success, ordinaryJoin!.errorCode);
|
|
Assert.False(ordinaryJoin.roomInstance!.isPrivate);
|
|
Assert.NotEqual(privateJoin.roomInstance.roomInstanceId,
|
|
ordinaryJoin.roomInstance.roomInstanceId);
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventParticipantsMayInviteAndAStrangerIsRefusedWithTheCreatorNamed()
|
|
{
|
|
// The gate used to be creator-only. The client offers the invite button to players who are
|
|
// already on the event, so an attendee was answered an anonymous 403 for a press the client
|
|
// had invited them to make.
|
|
const long OutsiderId = 99;
|
|
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db);
|
|
db.PlayerEventResponses.Add(new PlayerEventResponse
|
|
{
|
|
PlayerEventId = EventId,
|
|
PlayerId = InviteeId,
|
|
Type = EventResponses.Yes
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var participant = new EventsAPI(db);
|
|
SetUser(participant, InviteeId);
|
|
SetFormBody(participant, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={StrangerId}");
|
|
Assert.IsType<OkObjectResult>(await participant.BulkInvite());
|
|
Assert.Equal(EventResponses.Pending, (await db.PlayerEventResponses
|
|
.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == StrangerId)).Type);
|
|
|
|
var outsider = new EventsAPI(db);
|
|
SetUser(outsider, OutsiderId);
|
|
SetFormBody(outsider, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}");
|
|
|
|
var refused = Assert.IsType<ObjectResult>(await outsider.BulkInvite());
|
|
|
|
Assert.Equal(StatusCodes.Status403Forbidden, refused.StatusCode);
|
|
Assert.Contains($"\"CreatorPlayerId\":{CreatorId}", JsonSerializer.Serialize(refused.Value));
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnEventWhoseCreatorIsGoneMayBeInvitedToByAnyone()
|
|
{
|
|
// A wiped-and-reseeded database leaves events whose creating account no longer exists. Nobody
|
|
// is left to invite for them, so refusing every caller made the event permanently unusable.
|
|
var (db, previousFactories) = await CreateContextAsync();
|
|
await using var disposeDb = db;
|
|
try
|
|
{
|
|
await SeedEventAsync(db);
|
|
var orphan = await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == EventId);
|
|
orphan.CreatorPlayerId = 987654;
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = new EventsAPI(db);
|
|
SetUser(controller, StrangerId);
|
|
SetFormBody(controller, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}");
|
|
|
|
Assert.IsType<OkObjectResult>(await controller.BulkInvite());
|
|
}
|
|
finally
|
|
{
|
|
RestoreFactories(previousFactories);
|
|
}
|
|
}
|
|
}
|