using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using SlipItIn.Server.Data; using SlipItIn.Server.Hubs; using SlipItIn.Server.Services; using SlipItIn.Shared.DTOs; using SlipItIn.Shared.Models; using System.Security.Claims; namespace SlipItIn.Server.Tests; /// /// Tests für den GameHub mit gemocktem IGameService und gemockten SignalR-Abhängigkeiten. /// Echte DB (InMemory) wird nur für die Validierungs-Methoden des Hubs verwendet. /// public class GameHubTests { private readonly IDbContextFactory _factory = TestDbHelper.CreateFactory(); private readonly Mock _gameService = new(); private readonly Mock _groups = new(); private readonly Mock _clients = new(); private readonly Mock _callerProxy = new(); private readonly Mock _groupProxy = new(); private readonly Mock _clientProxy = new(); private readonly GameHub _hub; private const string ConnectionId = "test-connection-id"; private int _userId = 1; public GameHubTests() { _clients.Setup(c => c.Caller).Returns(_callerProxy.Object); _clients.Setup(c => c.Group(It.IsAny())).Returns(_groupProxy.Object); _clients.Setup(c => c.Client(It.IsAny())).Returns(_clientProxy.Object); _hub = new GameHub(_gameService.Object, _factory, NullLogger.Instance); SetupContext(); } private void SetupContext() { var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, _userId.ToString())], "TestAuth"); var context = new Mock(); context.Setup(c => c.ConnectionId).Returns(ConnectionId); context.Setup(c => c.User).Returns(new ClaimsPrincipal(identity)); _hub.Context = context.Object; _hub.Clients = _clients.Object; _hub.Groups = _groups.Object; } private void WithoutAuth() { var context = new Mock(); context.Setup(c => c.ConnectionId).Returns(ConnectionId); context.Setup(c => c.User).Returns((ClaimsPrincipal?)null); _hub.Context = context.Object; } private static string? ErrorMessageOf(Mock proxy) { // Die Aufrufe kommen als SendCoreAsync(string, object?[]) mit dem Payload in args[1] rein. var invocation = proxy.Invocations.FirstOrDefault(i => i.Arguments.Count >= 2 && i.Arguments[0] is string m && m == "Error"); if (invocation == null) return null; var payload = invocation.Arguments[1]; // Payload kann ein anonymes Objekt sein oder ein object?[] mit einem anonymen Objekt drin. if (payload is object?[] arr && arr.Length > 0) payload = arr[0]; return payload?.GetType().GetProperty("Message")?.GetValue(payload) as string; } private Game SeedGameWithPlayers(params (User user, string? connId)[] players) { using var db = _factory.CreateDbContext(); var game = new Game { LobbyCode = "ABC123", HostId = players[0].user.Id, Status = GameStatus.Lobby }; db.Games.Add(game); foreach (var (user, connId) in players) { db.Players.Add(new Player { UserId = user.Id, Game = game, ConnectionId = connId }); } db.SaveChanges(); return game; } private (User user, Player player) SeedUserWithPlayer(Game game) { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db, Guid.NewGuid().ToString("N")[..8], Guid.NewGuid().ToString("N")[..8] + "@t.local"); var player = new Player { UserId = user.Id, GameId = game.Id }; db.Players.Add(player); db.SaveChanges(); return (user, player); } // ---------- CreateLobby ---------- [Fact] public async Task CreateLobby_AddsCallerToGroupAndSendsLobbyCreated() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = new Game { Id = 7, LobbyCode = "XYZ789", HostId = user.Id }; _gameService.Setup(s => s.CreateGameAsync(user.Id, ConnectionId)).ReturnsAsync(game); await _hub.CreateLobby(); _groups.Verify(g => g.AddToGroupAsync(ConnectionId, "XYZ789", default), Times.Once); _callerProxy.Verify(p => p.SendCoreAsync("LobbyCreated", It.Is(a => a.Length == 1), default), Times.Once); } [Fact] public async Task CreateLobby_SendsUnauthorized_WhenNotAuthenticated() { WithoutAuth(); await _hub.CreateLobby(); Assert.Equal("Unauthorized", ErrorMessageOf(_callerProxy)); } [Fact] public async Task CreateLobby_SendsGenericError_WhenServiceThrows() { _gameService.Setup(s => s.CreateGameAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Host not found")); await _hub.CreateLobby(); Assert.Equal("An error occurred while creating the lobby.", ErrorMessageOf(_callerProxy)); } // ---------- JoinLobby ---------- [Fact] public async Task JoinLobby_AddsToGroupAndBroadcastsPlayerJoined() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = new Game { Id = 3, LobbyCode = "JOIN42", HostId = 99 }; _gameService.Setup(s => s.JoinGameAsync("JOIN42", user.Id, ConnectionId)).ReturnsAsync(game); _gameService.Setup(s => s.GetGameStateAsync(game.Id)).ReturnsAsync(new GameStateDto { GameId = 3 }); await _hub.JoinLobby("JOIN42"); _groups.Verify(g => g.AddToGroupAsync(ConnectionId, "JOIN42", default), Times.Once); _groupProxy.Verify(p => p.SendCoreAsync("PlayerJoined", It.IsAny(), default), Times.Once); } [Fact] public async Task JoinLobby_SendsUnauthorized_WhenNotAuthenticated() { WithoutAuth(); await _hub.JoinLobby("ANY"); Assert.Equal("Unauthorized", ErrorMessageOf(_callerProxy)); } [Fact] public async Task JoinLobby_SendsServiceMessage_WhenGameFullOrStarted() { _gameService.Setup(s => s.JoinGameAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Game is full")); await _hub.JoinLobby("FULL1"); Assert.Equal("Game is full", ErrorMessageOf(_callerProxy)); } [Fact] public async Task JoinLobby_SendsGenericError_WhenUnexpectedException() { _gameService.Setup(s => s.JoinGameAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("boom")); await _hub.JoinLobby("ANY"); Assert.Equal("An error occurred while joining the lobby.", ErrorMessageOf(_callerProxy)); } // ---------- PlayerReady ---------- [Fact] public async Task PlayerReady_BroadcastsGameState_WhenValid() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((user, ConnectionId)); var player = db.Players.Single(p => p.GameId == game.Id); _gameService.Setup(s => s.SetPlayerReadyAsync(game.Id, player.Id)) .ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); _gameService.Setup(s => s.GetGameStateAsync(game.Id)).ReturnsAsync(new GameStateDto { GameId = game.Id }); await _hub.PlayerReady(game.Id, player.Id); _groupProxy.Verify(p => p.SendCoreAsync("GameStateUpdated", It.IsAny(), default), Times.Once); } [Fact] public async Task PlayerReady_SendsError_WhenPlayerBelongsToOtherUser() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db, "me", "me@t.local"); var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((other, null)); var otherPlayer = db.Players.Single(p => p.GameId == game.Id); await _hub.PlayerReady(game.Id, otherPlayer.Id); Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy)); } [Fact] public async Task PlayerReady_SendsGenericError_WhenServiceThrows() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((user, ConnectionId)); var player = db.Players.Single(p => p.GameId == game.Id); _gameService.Setup(s => s.SetPlayerReadyAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Player not found")); await _hub.PlayerReady(game.Id, player.Id); Assert.Equal("An error occurred while setting player status.", ErrorMessageOf(_callerProxy)); } // ---------- StartGame ---------- [Fact] public async Task StartGame_BroadcastsGameStarted_AndSendsHandsToEachPlayer() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); _userId = host.Id; SetupContext(); var game = SeedGameWithPlayers((host, "conn-host")); var (_, otherPlayer) = SeedUserWithPlayer(game); using (var db2 = _factory.CreateDbContext()) { db2.Players.Single(p => p.Id == otherPlayer.Id).ConnectionId = "conn-other"; db2.SaveChanges(); } _gameService.Setup(s => s.StartGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); _gameService.Setup(s => s.DealCardsAsync(game.Id)).ReturnsAsync([ new PlayerCard { PlayerId = game.Players.First().Id }, new PlayerCard { PlayerId = otherPlayer.Id } ]); _gameService.Setup(s => s.GetPlayerHandAsync(It.IsAny())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid }); await _hub.StartGame(game.Id); _groupProxy.Verify(p => p.SendCoreAsync("GameStarted", It.IsAny(), default), Times.Once); // Beide Spieler erhalten ihre Hand (über Clients.Client) _clients.Verify(c => c.Client("conn-host"), Times.Once); _clients.Verify(c => c.Client("conn-other"), Times.Once); _clientProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny(), default), Times.Exactly(2)); } [Fact] public async Task StartGame_SendsHostOnlyError_WhenCallerIsNotHost() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); var notHost = TestDbHelper.SeedUser(db, "not", "not@t.local"); _userId = notHost.Id; SetupContext(); var game = SeedGameWithPlayers((host, null), (notHost, null)); await _hub.StartGame(game.Id); Assert.Equal("Only the host can start the game.", ErrorMessageOf(_callerProxy)); } [Fact] public async Task StartGame_SendsGenericError_WhenServiceThrows() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db); _userId = host.Id; SetupContext(); var game = SeedGameWithPlayers((host, null)); _gameService.Setup(s => s.StartGameAsync(It.IsAny())).ThrowsAsync(new Exception("boom")); await _hub.StartGame(game.Id); Assert.Equal("An error occurred while starting the game.", ErrorMessageOf(_callerProxy)); } // ---------- SubmitSlip ---------- [Fact] public async Task SubmitSlip_BroadcastsSlipSubmitted_WhenValid() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((user, ConnectionId)); var player = db.Players.Single(p => p.GameId == game.Id); _gameService.Setup(s => s.SubmitSlipAsync(game.Id, player.Id, 55)).ReturnsAsync(true); _gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); await _hub.SubmitSlip(game.Id, player.Id, 55); _groupProxy.Verify(p => p.SendCoreAsync("SlipSubmitted", It.IsAny(), default), Times.Once); } [Fact] public async Task SubmitSlip_SendsError_WhenPlayerNotOwnedByUser() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db, "me", "me@t.local"); var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((other, null)); var otherPlayer = db.Players.Single(p => p.GameId == game.Id); await _hub.SubmitSlip(game.Id, otherPlayer.Id, 1); Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy)); } [Fact] public async Task SubmitSlip_SendsGenericError_WhenServiceThrows() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((user, ConnectionId)); var player = db.Players.Single(p => p.GameId == game.Id); _gameService.Setup(s => s.SubmitSlipAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Card not found")); await _hub.SubmitSlip(game.Id, player.Id, 999); Assert.Equal("An error occurred while submitting slip.", ErrorMessageOf(_callerProxy)); } // ---------- ChallengeSlip ---------- [Fact] public async Task ChallengeSlip_BroadcastsAndNotifiesTargetPlayer() { using var db = _factory.CreateDbContext(); var challenger = TestDbHelper.SeedUser(db, "ch", "ch@t.local"); _userId = challenger.Id; SetupContext(); var game = SeedGameWithPlayers((challenger, ConnectionId)); var (_, targetPlayer) = SeedUserWithPlayer(game); using (var db2 = _factory.CreateDbContext()) { db2.Players.Single(p => p.Id == targetPlayer.Id).ConnectionId = "conn-target"; db2.SaveChanges(); } var challengerPlayer = db.Players.Single(p => p.UserId == challenger.Id); _gameService.Setup(s => s.CreateChallengeAsync(game.Id, challengerPlayer.Id, 42)) .ReturnsAsync(new SlipChallenge { Id = 1, ChallengingPlayerId = challengerPlayer.Id, TargetPlayerId = targetPlayer.Id, TargetCardId = 42, Status = ChallengeStatus.Pending }); _gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); await _hub.ChallengeSlip(game.Id, challengerPlayer.Id, 42); _groupProxy.Verify(p => p.SendCoreAsync("SlipChallenged", It.IsAny(), default), Times.Once); _clients.Verify(c => c.Client("conn-target"), Times.Once); _clientProxy.Verify(p => p.SendCoreAsync("ChallengeReceived", It.IsAny(), default), Times.Once); } [Fact] public async Task ChallengeSlip_SendsError_WhenPlayerNotOwnedByUser() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db, "me", "me@t.local"); var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((other, null)); var otherPlayer = db.Players.Single(p => p.GameId == game.Id); await _hub.ChallengeSlip(game.Id, otherPlayer.Id, 1); Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy)); } [Fact] public async Task ChallengeSlip_SendsGenericError_WhenServiceThrows() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); var game = SeedGameWithPlayers((user, ConnectionId)); var player = db.Players.Single(p => p.GameId == game.Id); _gameService.Setup(s => s.CreateChallengeAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("No active round found")); await _hub.ChallengeSlip(game.Id, player.Id, 1); Assert.Equal("An error occurred while challenging slip.", ErrorMessageOf(_callerProxy)); } // ---------- ResolveChallenge ---------- [Fact] public async Task ResolveChallenge_BroadcastsAndUpdatesHands_WhenTargetResolves() { using var db = _factory.CreateDbContext(); var target = TestDbHelper.SeedUser(db, "target", "target@t.local"); _userId = target.Id; SetupContext(); var game = SeedGameWithPlayers((target, "conn-target")); var targetPlayer = db.Players.Single(p => p.UserId == target.Id); var (_, challengingPlayer) = SeedUserWithPlayer(game); using (var db2 = _factory.CreateDbContext()) { db2.Players.Single(p => p.Id == challengingPlayer.Id).ConnectionId = "conn-challenger"; var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; db2.GameRounds.Add(round); var phrase = TestDbHelper.SeedPhrases(db2, 1).Single(); var card = new PlayerCard { PlayerId = targetPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; db2.PlayerCards.Add(card); db2.SlipChallenges.Add(new SlipChallenge { Id = 77, GameRoundId = round.Id, ChallengingPlayerId = challengingPlayer.Id, TargetPlayerId = targetPlayer.Id, TargetCardId = card.Id, Status = ChallengeStatus.Pending }); db2.SaveChanges(); } _gameService.Setup(s => s.ResolveChallengeAsync(77, true)) .ReturnsAsync(new SlipChallenge { Id = 77, GameRound = new GameRound { GameId = game.Id }, ChallengingPlayerId = challengingPlayer.Id, TargetPlayerId = targetPlayer.Id, Status = ChallengeStatus.Approved }); _gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); _gameService.Setup(s => s.GetPlayerHandAsync(It.IsAny())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid }); await _hub.ResolveChallenge(77, true); _groupProxy.Verify(p => p.SendCoreAsync("ChallengeResolved", It.IsAny(), default), Times.Once); _clientProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny(), default), Times.Exactly(2)); } [Fact] public async Task ResolveChallenge_SendsError_WhenCallerIsNotTarget() { using var db = _factory.CreateDbContext(); var target = TestDbHelper.SeedUser(db, "target", "target@t.local"); var notTarget = TestDbHelper.SeedUser(db, "not", "not@t.local"); _userId = notTarget.Id; SetupContext(); var game = SeedGameWithPlayers((target, null)); var targetPlayer = db.Players.Single(p => p.UserId == target.Id); using (var db2 = _factory.CreateDbContext()) { db2.SlipChallenges.Add(new SlipChallenge { Id = 78, GameRoundId = 1, ChallengingPlayerId = 1, TargetPlayerId = targetPlayer.Id, TargetCardId = 1, Status = ChallengeStatus.Pending }); db2.SaveChanges(); } await _hub.ResolveChallenge(78, true); Assert.Equal("Only the accused player can resolve this challenge", ErrorMessageOf(_callerProxy)); } [Fact] public async Task ResolveChallenge_SendsMessage_WhenChallengeNotFound() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); await _hub.ResolveChallenge(999, true); Assert.Equal("Challenge not found", ErrorMessageOf(_callerProxy)); } // ---------- RequestGameState ---------- [Fact] public async Task RequestGameState_SendsStateToCaller_WhenParticipant() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); SeedGameWithPlayers((user, ConnectionId)); var gameId = db.Games.Single().Id; _gameService.Setup(s => s.GetGameStateAsync(gameId)).ReturnsAsync(new GameStateDto { GameId = gameId }); await _hub.RequestGameState(gameId); _callerProxy.Verify(p => p.SendCoreAsync("GameStateUpdated", It.IsAny(), default), Times.Once); } [Fact] public async Task RequestGameState_SendsError_WhenNotParticipant() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); _userId = user.Id; SetupContext(); SeedGameWithPlayers((other, null)); var gameId = db.Games.Single().Id; await _hub.RequestGameState(gameId); Assert.Equal("User is not part of this game", ErrorMessageOf(_callerProxy)); } [Fact] public async Task RequestGameState_SendsGenericError_WhenServiceThrows() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); SeedGameWithPlayers((user, ConnectionId)); var gameId = db.Games.Single().Id; _gameService.Setup(s => s.GetGameStateAsync(It.IsAny())).ThrowsAsync(new Exception("boom")); await _hub.RequestGameState(gameId); Assert.Equal("An error occurred while requesting game state.", ErrorMessageOf(_callerProxy)); } // ---------- OnConnectedAsync ---------- [Fact] public async Task OnConnectedAsync_StoresConnectionId_WhenUserInActiveGame() { using var db = _factory.CreateDbContext(); var user = TestDbHelper.SeedUser(db); _userId = user.Id; SetupContext(); SeedGameWithPlayers((user, null)); await _hub.OnConnectedAsync(); using var verify = _factory.CreateDbContext(); Assert.Equal(ConnectionId, verify.Players.Single(p => p.UserId == user.Id).ConnectionId); } [Fact] public async Task OnConnectedAsync_DoesNotThrow_WhenNotAuthenticated() { WithoutAuth(); await _hub.OnConnectedAsync(); // darf nicht werfen } // ---------- OnDisconnectedAsync ---------- [Fact] public async Task OnDisconnectedAsync_Completes() { await _hub.OnDisconnectedAsync(null); } }