From 510cc0e5202093cdf897992905231194e574963f Mon Sep 17 00:00:00 2001 From: Tim Krampitz Date: Sat, 22 Aug 2026 19:31:45 +0200 Subject: [PATCH] =?UTF-8?q?Testprojekt=20hinzugef=C3=BCgt=20&=20umfassende?= =?UTF-8?q?=20Server-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Testprojekt SlipItIn.Server.Tests erstellt und in die Solution eingebunden. Unit- und Integrationstests für AuthController, DbSeeder, GameHub und GameService implementiert. TestDbHelper für InMemory-DbContexts und Testdaten hinzugefügt. Tests prüfen Erfolgs- und Fehlerfälle inkl. Validierungen, Fehlerbehandlung und DB-Zustände. --- SlipItIN.slnx | 1 + SlipItIn.Server.Tests/AuthControllerTests.cs | 203 ++++++ SlipItIn.Server.Tests/DbSeederTests.cs | 61 ++ SlipItIn.Server.Tests/GameHubTests.cs | 606 ++++++++++++++++++ SlipItIn.Server.Tests/GameServiceTests.cs | 504 +++++++++++++++ .../SlipItIn.Server.Tests.csproj | 27 + SlipItIn.Server.Tests/TestDbHelper.cs | 56 ++ SlipItIn.slnx | 1 + 8 files changed, 1459 insertions(+) create mode 100644 SlipItIn.Server.Tests/AuthControllerTests.cs create mode 100644 SlipItIn.Server.Tests/DbSeederTests.cs create mode 100644 SlipItIn.Server.Tests/GameHubTests.cs create mode 100644 SlipItIn.Server.Tests/GameServiceTests.cs create mode 100644 SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj create mode 100644 SlipItIn.Server.Tests/TestDbHelper.cs diff --git a/SlipItIN.slnx b/SlipItIN.slnx index 4b99562..9874d5e 100644 --- a/SlipItIN.slnx +++ b/SlipItIN.slnx @@ -1,5 +1,6 @@ + diff --git a/SlipItIn.Server.Tests/AuthControllerTests.cs b/SlipItIn.Server.Tests/AuthControllerTests.cs new file mode 100644 index 0000000..8043746 --- /dev/null +++ b/SlipItIn.Server.Tests/AuthControllerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using SlipItIn.Server.Controllers; +using SlipItIn.Server.Data; +using SlipItIn.Shared.DTOs; +using SlipItIn.Shared.Models; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace SlipItIn.Server.Tests; + +public class AuthControllerTests +{ + private readonly IDbContextFactory _factory = TestDbHelper.CreateFactory(); + private readonly AuthController _sut; + + public AuthControllerTests() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "TestKeyThatIsAtLeast32CharactersLong!!", + ["Jwt:Issuer"] = "TestIssuer", + ["Jwt:Audience"] = "TestAudience", + ["Jwt:ExpirationMinutes"] = "60" + }) + .Build(); + _sut = new AuthController(_factory, config); + } + + // ---------- Register ---------- + + [Fact] + public async Task Register_ReturnsOk_WithTokenAndUserData() + { + var result = await _sut.Register(new RegisterRequestDto + { + Username = " Alice ", + Email = " ALICE@Test.Local ", + Password = "Secret123!" + }); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.Equal("Alice", dto.Username); // getrimmt + Assert.Equal("alice@test.local", dto.Email); // getrimmt + lowercase + Assert.True(dto.UserId > 0); + Assert.False(string.IsNullOrWhiteSpace(dto.Token)); + Assert.True(dto.ExpiresAtUtc > DateTime.UtcNow); + + // Token enthält die erwarteten Claims und ist valide lesbar + var token = new JwtSecurityTokenHandler().ReadJwtToken(dto.Token); + Assert.Equal("TestIssuer", token.Issuer); + Assert.Contains("TestAudience", token.Audiences); + Assert.Equal(dto.UserId.ToString(), token.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value); + } + + [Theory] + [InlineData("", "a@b.c", "pw")] + [InlineData("user", "", "pw")] + [InlineData("user", "a@b.c", "")] + [InlineData(" ", "a@b.c", "pw")] + public async Task Register_ReturnsBadRequest_WhenFieldsMissing(string username, string email, string password) + { + var result = await _sut.Register(new RegisterRequestDto { Username = username, Email = email, Password = password }); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task Register_ReturnsConflict_WhenEmailExists_CaseInsensitive() + { + using var db = _factory.CreateDbContext(); + TestDbHelper.SeedUser(db, "existing", "alice@test.local"); + + var result = await _sut.Register(new RegisterRequestDto + { + Username = "newuser", + Email = "ALICE@TEST.LOCAL", + Password = "Secret123!" + }); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task Register_ReturnsConflict_WhenUsernameExists_CaseInsensitive() + { + using var db = _factory.CreateDbContext(); + TestDbHelper.SeedUser(db, "alice", "other@test.local"); + + var result = await _sut.Register(new RegisterRequestDto + { + Username = "ALICE", + Email = "new@test.local", + Password = "Secret123!" + }); + + Assert.IsType(result.Result); + } + + // ---------- Login ---------- + + [Fact] + public async Task Login_ReturnsOk_WithValidCredentials() + { + using var db = _factory.CreateDbContext(); + db.Users.Add(new User + { + Username = "bob", + Email = "bob@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Secret123!") + }); + await db.SaveChangesAsync(); + + var result = await _sut.Login(new LoginRequestDto { Email = " BOB@test.local ", Password = "Secret123!" }); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.Equal("bob", dto.Username); + Assert.False(string.IsNullOrWhiteSpace(dto.Token)); + } + + [Theory] + [InlineData("", "pw")] + [InlineData("a@b.c", "")] + public async Task Login_ReturnsBadRequest_WhenFieldsMissing(string email, string password) + { + var result = await _sut.Login(new LoginRequestDto { Email = email, Password = password }); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task Login_ReturnsUnauthorized_WhenUserNotFound() + { + var result = await _sut.Login(new LoginRequestDto { Email = "ghost@test.local", Password = "Secret123!" }); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task Login_ReturnsUnauthorized_WhenPasswordWrong() + { + using var db = _factory.CreateDbContext(); + db.Users.Add(new User + { + Username = "bob", + Email = "bob@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("CorrectPassword") + }); + await db.SaveChangesAsync(); + + var result = await _sut.Login(new LoginRequestDto { Email = "bob@test.local", Password = "WrongPassword" }); + + Assert.IsType(result.Result); + } + + // ---------- Me ---------- + + [Fact] + public void Me_ReturnsUserData_FromClaims() + { + var identity = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "alice"), + new Claim(ClaimTypes.Email, "alice@test.local") + ], "TestAuth"); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + + var result = _sut.Me(); + + var ok = Assert.IsType(result.Result); + var value = ok.Value!; + Assert.Equal(42, (int)value.GetType().GetProperty("UserId")!.GetValue(value)!); + Assert.Equal("alice", (string)value.GetType().GetProperty("Username")!.GetValue(value)!); + Assert.Equal("alice@test.local", (string)value.GetType().GetProperty("Email")!.GetValue(value)!); + } + + [Fact] + public void Me_ReturnsUnauthorized_WhenNameIdentifierClaimMissing() + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")], "TestAuth"); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + + var result = _sut.Me(); + + Assert.IsType(result.Result); + } +} diff --git a/SlipItIn.Server.Tests/DbSeederTests.cs b/SlipItIn.Server.Tests/DbSeederTests.cs new file mode 100644 index 0000000..d85532c --- /dev/null +++ b/SlipItIn.Server.Tests/DbSeederTests.cs @@ -0,0 +1,61 @@ +using SlipItIn.Server.Data; + +namespace SlipItIn.Server.Tests; + +public class DbSeederTests +{ + [Fact] + public async Task SeedAsync_SeedsUsersAndPhrases_WhenDatabaseIsEmpty() + { + var factory = TestDbHelper.CreateFactory(); + using var db = factory.CreateDbContext(); + + await DbSeeder.SeedAsync(db); + + Assert.Equal(2, db.Users.Count()); + Assert.True(db.Phrases.Count() >= 5); + Assert.All(db.Phrases, p => Assert.True(p.IsActive)); + } + + [Fact] + public async Task SeedAsync_DoesNothing_WhenUsersAlreadyExist() + { + var factory = TestDbHelper.CreateFactory(); + using var db = factory.CreateDbContext(); + TestDbHelper.SeedUser(db); + + await DbSeeder.SeedAsync(db); + + Assert.Equal(1, db.Users.Count()); + Assert.Empty(db.Phrases); + } + + [Fact] + public async Task SeedAsync_DoesNothing_WhenPhrasesAlreadyExist() + { + var factory = TestDbHelper.CreateFactory(); + using var db = factory.CreateDbContext(); + TestDbHelper.SeedPhrases(db, 1); + + await DbSeeder.SeedAsync(db); + + Assert.Equal(1, db.Users.Count()); // nur der Creator-User des Phrase-Seedings + Assert.Equal(1, db.Phrases.Count()); + } + + [Fact] + public async Task SeedAsync_IsIdempotent_WhenCalledTwice() + { + var factory = TestDbHelper.CreateFactory(); + using var db = factory.CreateDbContext(); + + await DbSeeder.SeedAsync(db); + var userCount = db.Users.Count(); + var phraseCount = db.Phrases.Count(); + + await DbSeeder.SeedAsync(db); + + Assert.Equal(userCount, db.Users.Count()); + Assert.Equal(phraseCount, db.Phrases.Count()); + } +} diff --git a/SlipItIn.Server.Tests/GameHubTests.cs b/SlipItIn.Server.Tests/GameHubTests.cs new file mode 100644 index 0000000..4c5bc44 --- /dev/null +++ b/SlipItIn.Server.Tests/GameHubTests.cs @@ -0,0 +1,606 @@ +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); + } +} diff --git a/SlipItIn.Server.Tests/GameServiceTests.cs b/SlipItIn.Server.Tests/GameServiceTests.cs new file mode 100644 index 0000000..33e5b67 --- /dev/null +++ b/SlipItIn.Server.Tests/GameServiceTests.cs @@ -0,0 +1,504 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SlipItIn.Server.Data; +using SlipItIn.Server.Services; +using SlipItIn.Shared.Models; + +namespace SlipItIn.Server.Tests; + +public class GameServiceTests +{ + private readonly IDbContextFactory _factory = TestDbHelper.CreateFactory(); + private readonly GameService _sut; + + public GameServiceTests() + { + _sut = new GameService(_factory, NullLogger.Instance); + } + + // ---------- CreateGameAsync ---------- + + [Fact] + public async Task CreateGameAsync_CreatesGameWithHostAsFirstPlayer() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + + var game = await _sut.CreateGameAsync(host.Id, "conn-1"); + + Assert.Equal(host.Id, game.HostId); + Assert.Equal(GameStatus.Lobby, game.Status); + Assert.Equal(6, game.LobbyCode.Length); + Assert.Single(game.Players); + Assert.Equal(host.Id, game.Players.First().UserId); + Assert.Equal("conn-1", game.Players.First().ConnectionId); + } + + [Fact] + public async Task CreateGameAsync_Throws_WhenHostNotFound() + { + await Assert.ThrowsAsync(() => _sut.CreateGameAsync(999)); + } + + // ---------- JoinGameAsync ---------- + + [Fact] + public async Task JoinGameAsync_AddsPlayerToGame() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local"); + var (game, _) = TestDbHelper.SeedGame(db, host); + + var result = await _sut.JoinGameAsync(game.LobbyCode, joiner.Id, "conn-2"); + + Assert.Equal(game.Id, result.Id); + Assert.Equal(2, db.Players.Count(p => p.GameId == game.Id)); + Assert.Equal("conn-2", db.Players.Single(p => p.UserId == joiner.Id).ConnectionId); + } + + [Fact] + public async Task JoinGameAsync_Throws_WhenGameNotFound() + { + using var db = _factory.CreateDbContext(); + var user = TestDbHelper.SeedUser(db); + + await Assert.ThrowsAsync(() => _sut.JoinGameAsync("NOCODE", user.Id)); + } + + [Fact] + public async Task JoinGameAsync_Throws_WhenGameAlreadyStarted() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local"); + var (game, _) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress); + + await Assert.ThrowsAsync(() => _sut.JoinGameAsync(game.LobbyCode, joiner.Id)); + } + + [Fact] + public async Task JoinGameAsync_Throws_WhenGameIsFull() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var (game, _) = TestDbHelper.SeedGame(db, host); + game.MaxPlayers = 1; + await db.SaveChangesAsync(); + var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local"); + + await Assert.ThrowsAsync(() => _sut.JoinGameAsync(game.LobbyCode, joiner.Id)); + } + + [Fact] + public async Task JoinGameAsync_Throws_WhenUserNotFound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + await Assert.ThrowsAsync(() => _sut.JoinGameAsync(game.LobbyCode, 999)); + } + + [Fact] + public async Task JoinGameAsync_Throws_WhenUserAlreadyInGame() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + await Assert.ThrowsAsync(() => _sut.JoinGameAsync(game.LobbyCode, host.Id)); + } + + // ---------- StartGameAsync ---------- + + [Fact] + public async Task StartGameAsync_SetsStatusAndCreatesFirstRound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + var result = await _sut.StartGameAsync(game.Id); + + Assert.Equal(GameStatus.InProgress, result.Status); + Assert.NotNull(result.StartedAt); + var round = db.GameRounds.Single(r => r.GameId == game.Id); + Assert.Equal(1, round.RoundNumber); + Assert.Equal(RoundStatus.Waiting, round.Status); + } + + [Fact] + public async Task StartGameAsync_Throws_WhenGameNotFound() + { + await Assert.ThrowsAsync(() => _sut.StartGameAsync(999)); + } + + // ---------- SetPlayerReadyAsync ---------- + + [Fact] + public async Task SetPlayerReadyAsync_MarksPlayerReady() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + + var result = await _sut.SetPlayerReadyAsync(game.Id, hostPlayer.Id); + + Assert.Equal(game.Id, result.Id); + using var verify = _factory.CreateDbContext(); + Assert.True(verify.Players.Single(p => p.Id == hostPlayer.Id).IsReady); + } + + [Fact] + public async Task SetPlayerReadyAsync_Throws_WhenPlayerNotFound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + await Assert.ThrowsAsync(() => _sut.SetPlayerReadyAsync(game.Id, 999)); + } + + // ---------- DealCardsAsync ---------- + + [Fact] + public async Task DealCardsAsync_DealsFiveCardsPerPlayerAndActivatesRound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, _) = TestDbHelper.SeedGame(db, host); + db.Players.Add(new Player { UserId = other.Id, GameId = game.Id }); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting }; + db.GameRounds.Add(round); + TestDbHelper.SeedPhrases(db, 10); + await db.SaveChangesAsync(); + + var cards = await _sut.DealCardsAsync(game.Id); + + Assert.Equal(10, cards.Count); // 2 Spieler x 5 Karten + Assert.Equal(5, cards.Count(c => c.PlayerId == game.Players.First().Id)); + Assert.All(cards, c => Assert.False(c.IsUsed)); + using var verify = _factory.CreateDbContext(); + Assert.Equal(RoundStatus.Active, verify.GameRounds.Single(r => r.Id == round.Id).Status); + } + + [Fact] + public async Task DealCardsAsync_Throws_WhenNoWaitingRound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + TestDbHelper.SeedPhrases(db, 10); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => _sut.DealCardsAsync(game.Id)); + } + + [Fact] + public async Task DealCardsAsync_Throws_WhenNotEnoughPhrases() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + db.GameRounds.Add(new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting }); + TestDbHelper.SeedPhrases(db, 4); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => _sut.DealCardsAsync(game.Id)); + } + + // ---------- SubmitSlipAsync ---------- + + [Fact] + public async Task SubmitSlipAsync_MarksCardAsUsed() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + await db.SaveChangesAsync(); + + var result = await _sut.SubmitSlipAsync(game.Id, hostPlayer.Id, card.Id); + + Assert.True(result); + using var verify = _factory.CreateDbContext(); + Assert.True(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed); + } + + [Fact] + public async Task SubmitSlipAsync_Throws_WhenCardNotFound() + { + await Assert.ThrowsAsync(() => _sut.SubmitSlipAsync(1, 1, 999)); + } + + [Fact] + public async Task SubmitSlipAsync_Throws_WhenCardBelongsToOtherPlayer() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + await db.SaveChangesAsync(); + + // otherPlayer versucht, die Karte des Hosts zu verwenden + await Assert.ThrowsAsync(() => _sut.SubmitSlipAsync(game.Id, otherPlayer.Id, card.Id)); + } + + // ---------- CreateChallengeAsync ---------- + + [Fact] + public async Task CreateChallengeAsync_CreatesPendingChallenge() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + await db.SaveChangesAsync(); + + var challenge = await _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id); + + Assert.Equal(round.Id, challenge.GameRoundId); + Assert.Equal(hostPlayer.Id, challenge.ChallengingPlayerId); + Assert.Equal(otherPlayer.Id, challenge.TargetPlayerId); + Assert.Equal(card.Id, challenge.TargetCardId); + Assert.Equal(ChallengeStatus.Pending, challenge.Status); + } + + [Fact] + public async Task CreateChallengeAsync_Throws_WhenTargetCardNotFound() + { + await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(1, 1, 999)); + } + + [Fact] + public async Task CreateChallengeAsync_Throws_WhenNoActiveRound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting }; + db.GameRounds.Add(round); + var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id)); + } + + // ---------- ResolveChallengeAsync ---------- + + [Fact] + public async Task ResolveChallengeAsync_Approved_KeepsCardWithTargetPlayer() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + var challenge = new SlipChallenge + { + GameRoundId = round.Id, + ChallengingPlayerId = hostPlayer.Id, + TargetPlayerId = otherPlayer.Id, + TargetCardId = card.Id, + Status = ChallengeStatus.Pending + }; + db.SlipChallenges.Add(challenge); + await db.SaveChangesAsync(); + + var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: true); + + Assert.Equal(ChallengeStatus.Approved, result.Status); + Assert.NotNull(result.ResolvedAt); + // Karte bleibt beim Beschuldigten + using var verify = _factory.CreateDbContext(); + Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); + } + + [Fact] + public async Task ResolveChallengeAsync_Rejected_TransfersCardToChallenger() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id }; + db.PlayerCards.Add(card); + var challenge = new SlipChallenge + { + GameRoundId = round.Id, + ChallengingPlayerId = hostPlayer.Id, + TargetPlayerId = otherPlayer.Id, + TargetCardId = card.Id, + Status = ChallengeStatus.Pending + }; + db.SlipChallenges.Add(challenge); + await db.SaveChangesAsync(); + + var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: false); + + Assert.Equal(ChallengeStatus.Rejected, result.Status); + Assert.NotNull(result.ResolvedAt); + // Karte geht an den Beschuldiger + using var verify = _factory.CreateDbContext(); + Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); + } + + [Fact] + public async Task ResolveChallengeAsync_Throws_WhenChallengeNotFound() + { + await Assert.ThrowsAsync(() => _sut.ResolveChallengeAsync(999, true)); + } + + // ---------- GetGameStateAsync ---------- + + [Fact] + public async Task GetGameStateAsync_ReturnsStateWithoutRound_WhenNoRoundsExist() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + var state = await _sut.GetGameStateAsync(game.Id); + + Assert.Equal(game.Id, state.GameId); + Assert.Equal(game.LobbyCode, state.LobbyCode); + Assert.Equal("Lobby", state.Status); + Assert.Single(state.Players); + Assert.Equal(host.Username, state.Players[0].Username); + Assert.Equal(0, state.CurrentRound); + Assert.Equal(45, state.RoundTimeRemaining); // Default RoundDurationSeconds + } + + [Fact] + public async Task GetGameStateAsync_ComputesRemainingTime_WhenRoundActive() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + game.RoundDurationSeconds = 45; + db.GameRounds.Add(new GameRound + { + GameId = game.Id, + RoundNumber = 2, + Status = RoundStatus.Active, + StartedAt = DateTime.UtcNow.AddSeconds(-20) + }); + await db.SaveChangesAsync(); + + var state = await _sut.GetGameStateAsync(game.Id); + + Assert.Equal(2, state.CurrentRound); + Assert.InRange(state.RoundTimeRemaining, 24, 25); + } + + [Fact] + public async Task GetGameStateAsync_ClampsRemainingTimeToZero_WhenRoundExpired() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + db.GameRounds.Add(new GameRound + { + GameId = game.Id, + RoundNumber = 1, + Status = RoundStatus.Active, + StartedAt = DateTime.UtcNow.AddSeconds(-120) + }); + await db.SaveChangesAsync(); + + var state = await _sut.GetGameStateAsync(game.Id); + + Assert.Equal(0, state.RoundTimeRemaining); + } + + [Fact] + public async Task GetGameStateAsync_Throws_WhenGameNotFound() + { + await Assert.ThrowsAsync(() => _sut.GetGameStateAsync(999)); + } + + // ---------- GetPlayerHandAsync ---------- + + [Fact] + public async Task GetPlayerHandAsync_ReturnsCardsWithPhraseText() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var round = new GameRound { GameId = game.Id, RoundNumber = 1 }; + db.GameRounds.Add(round); + var phrases = TestDbHelper.SeedPhrases(db, 2); + db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id }); + db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[1].Id, GameRoundId = round.Id, IsUsed = true }); + await db.SaveChangesAsync(); + + var hand = await _sut.GetPlayerHandAsync(hostPlayer.Id); + + Assert.Equal(hostPlayer.Id, hand.PlayerId); + Assert.Equal(2, hand.Cards.Count); + Assert.Contains(hand.Cards, c => c.Text == "Phrase 1" && !c.IsUsed); + Assert.Contains(hand.Cards, c => c.Text == "Phrase 2" && c.IsUsed); + } + + [Fact] + public async Task GetPlayerHandAsync_ReturnsEmptyHand_WhenNoCards() + { + var hand = await _sut.GetPlayerHandAsync(999); + + Assert.Equal(999, hand.PlayerId); + Assert.Empty(hand.Cards); + } + + // ---------- GetGameAsync ---------- + + [Fact] + public async Task GetGameAsync_ReturnsGame() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var (game, _) = TestDbHelper.SeedGame(db, host); + + var result = await _sut.GetGameAsync(game.Id); + + Assert.Equal(game.Id, result.Id); + } + + [Fact] + public async Task GetGameAsync_Throws_WhenGameNotFound() + { + await Assert.ThrowsAsync(() => _sut.GetGameAsync(999)); + } +} diff --git a/SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj b/SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj new file mode 100644 index 0000000..561c8b9 --- /dev/null +++ b/SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SlipItIn.Server.Tests/TestDbHelper.cs b/SlipItIn.Server.Tests/TestDbHelper.cs new file mode 100644 index 0000000..ef76233 --- /dev/null +++ b/SlipItIn.Server.Tests/TestDbHelper.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using SlipItIn.Server.Data; +using SlipItIn.Shared.Models; + +namespace SlipItIn.Server.Tests; + +/// +/// Hilfsmethoden zum Erstellen isolierter InMemory-DbContexts und Testdaten. +/// +public static class TestDbHelper +{ + public static IDbContextFactory CreateFactory() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new TestDbContextFactory(options); + } + + public static User SeedUser(SlipItInDbContext db, string username = "user", string email = "user@test.local", bool isActive = true) + { + var user = new User { Username = username, Email = email, PasswordHash = "hash", IsActive = isActive }; + db.Users.Add(user); + db.SaveChanges(); + return user; + } + + public static List SeedPhrases(SlipItInDbContext db, int count, bool isActive = true) + { + var creator = SeedUser(db, "creator-" + Guid.NewGuid().ToString("N")[..8], Guid.NewGuid().ToString("N")[..8] + "@t.local"); + var phrases = Enumerable.Range(1, count) + .Select(i => new Phrase { Text = $"Phrase {i}", CreatorId = creator.Id, IsActive = isActive }) + .ToList(); + db.Phrases.AddRange(phrases); + db.SaveChanges(); + return phrases; + } + + /// Erstellt ein Spiel inkl. Host-Spieler über den echten GameService-Pfad (direkt in DB). + public static (Game game, Player hostPlayer) SeedGame(SlipItInDbContext db, User host, GameStatus status = GameStatus.Lobby) + { + var game = new Game { LobbyCode = Guid.NewGuid().ToString("N")[..6].ToUpper(), HostId = host.Id, Status = status }; + var hostPlayer = new Player { UserId = host.Id, Game = game }; + game.Players.Add(hostPlayer); + db.Games.Add(game); + db.SaveChanges(); + return (game, hostPlayer); + } + + private class TestDbContextFactory(DbContextOptions options) : IDbContextFactory + { + public SlipItInDbContext CreateDbContext() => new(options); + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new SlipItInDbContext(options)); + } +} diff --git a/SlipItIn.slnx b/SlipItIn.slnx index 4b99562..9874d5e 100644 --- a/SlipItIn.slnx +++ b/SlipItIn.slnx @@ -1,5 +1,6 @@ +