Compare commits

...

9 Commits

Author SHA1 Message Date
tim
118660a207 Merge pull request 'Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet' (#2) from feature/techdebt-cleanup into master
Some checks failed
Build / build (push) Successful in 2m11s
OpenWiki Update / update (push) Has been cancelled
Reviewed-on: #2
2026-09-01 19:00:25 +00:00
Tim Krampitz
7953b8ddd0 P1: Challenger-Zugehörigkeit zum Spiel validieren; P2: MAUI-Workload im CI installieren
All checks were successful
Build / build (pull_request) Successful in 6m10s
- GameService.CreateChallengeAsync prüft jetzt, dass challengingPlayerId zum gameId gehört
- Zwei neue Tests für Cross-Game-/Fremdspieler-Challenges
- CI: dotnet workload install maui vor dem Restore, damit MAUI auf frischen Runnern baut
2026-09-01 20:50:46 +02:00
Tim Krampitz
660fc4d311 Fix CI build workflow, index casing, and test assertions
All checks were successful
Build / build (pull_request) Successful in 2m16s
2026-08-23 14:46:09 +02:00
Tim Krampitz
5e22f0ab9b Merge branch 'feature/techdebt-cleanup' of https://git.krampitz.win/tim/SlipItIn into feature/techdebt-cleanup
Some checks failed
Build / build (pull_request) Failing after 3m29s
Signed-off-by: Tim Krampitz <Tim.Krampitz@live.com>
2026-08-22 20:14:46 +02:00
Tim Krampitz
af6528ee68 Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Some checks failed
Build / build (pull_request) Has been cancelled
Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
2026-08-22 19:37:54 +02:00
Tim Krampitz
7d4344c062 Chore
All checks were successful
OpenWiki Update / update (push) Successful in 13m54s
2026-08-22 19:37:01 +02:00
Tim Krampitz
510cc0e520 Testprojekt hinzugefügt & umfassende Server-Tests
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.
2026-08-22 19:31:45 +02:00
Tim Krampitz
b1435b2c1e Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-22 19:10:32 +02:00
Tim Krampitz
fe1319a34c Removed obsolete documentation and planning. 2026-08-22 19:10:29 +02:00
35 changed files with 2138 additions and 169 deletions

40
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Build
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore
run: dotnet restore SlipItIn.slnx
- name: Build Server-Stack
run: |
dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj --no-restore --configuration Release
dotnet build SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj --no-restore --configuration Release
dotnet build SlipItIn.Server/SlipItIn.Server.csproj --no-restore --configuration Release
dotnet build SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release
dotnet build SlipItIn.AppHost/SlipItIn.AppHost.csproj --no-restore --configuration Release
- name: Build MAUI Client
run: dotnet build SlipItIn/SlipItIn.csproj --no-restore --configuration Debug
- name: Run Server Tests
run: dotnet test SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release

2
.gitignore vendored
View File

@@ -361,6 +361,8 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Legacy: urspruenglich SQLite-Dateien — die Anwendung nutzt inzwischen PostgreSQL (via Aspire).
# Eintraege bleiben als Sicherheitsnetz, falls lokal noch alte DB-Dateien existieren.
/SlipItIn.Server/slipitIn.db
/SlipItIn.Server/slipitIn.db-shm
/SlipItIn.Server/slipitIn.db-wal

View File

@@ -1 +1,53 @@
# SlipItIn
# SlipItIn
Ein Echtzeit-Multiplayer-Partyspiel: Jeder Spieler erhält Phrasen-Karten und versucht, sie unbemerkt in die laufende Runde zu "slippen". Wer einen fremden Slip erkennt, kann ihn anfechten — bei einer falschen Beschuldigung wandert die Phrase allerdings zum Beschuldiger.
## Tech-Stack
- **Client:** .NET MAUI (.NET 10) mit CommunityToolkit.Mvvm und SignalR-Client
- **Server:** ASP.NET Core 10 Web API + SignalR, EF Core mit PostgreSQL
- **Orchestrierung:** .NET Aspire (`SlipItIn.AppHost`) startet Server inklusive PostgreSQL-Container
- **Shared:** Gemeinsame Modelle und DTOs in `SlipItIn.Shared`
## Voraussetzungen
- .NET 10 SDK
- Docker oder Podman (für den PostgreSQL-Container, den Aspire bereitstellt)
- MAUI-Workload: `dotnet workload install maui`
- Für den Server allein genügt die Aspire-Workload-Unterstützung des SDKs
## Ausführen
```powershell
dotnet run --project SlipItIn.AppHost/SlipItIn.AppHost.csproj
```
Der AppHost startet PostgreSQL (Container), wendet EF-Migrationen automatisch an und seedet Testdaten. Die MAUI-App verbindet sich standardmäßig mit `https://localhost:7274` (Android-Emulator: `http://10.0.2.2:5234`).
## Konfiguration
| Key | Zweck |
| --- | --- |
| `Jwt:Key` | Signaturschlüssel für JWT (mindestens 32 Zeichen). **Nicht committen** — via User-Secrets oder Umgebungsvariable setzen. In Development ist ein Fallback-Key hinterlegt; in Production bricht der Server ohne konfigurierten Key den Start ab. |
| `Jwt:Issuer` / `Jwt:Audience` | Token-Aussteller bzw. -Empfänger (Defaults: `SlipItInServer` / `SlipItInClient`) |
| `ConnectionStrings:postgresdb` | Wird lokal von Aspire injiziert; muss nicht manuell gesetzt werden |
## Projektstruktur
- `SlipItIn/` — MAUI-Client (Views, ViewModels, Services, Offline-Queue)
- `SlipItIn.Server/` — Web API, SignalR-Hub, EF Core, Migrations
- `SlipItIn.Shared/` — Datenbank-Entitäten und SignalR-DTOs
- `SlipItIn.AppHost/` — Aspire-Orchestrierung
- `SlipItIn.ServiceDefaults/` — Geteilte Aspire-Service-Defaults
- `openwiki/` — Generierte Dokumentation (wird automatisch aktualisiert, bitte nicht von Hand editieren)
## Build
```powershell
dotnet build SlipItIn.slnx
```
## Hinweise
- Dokumentationssprache: Handgepflegte Dateien (diese README) sind Deutsch, die generierte OpenWiki-Dokumentation ist Englisch.
- `.gitignore` enthält noch Einträge für `slipitIn.db` — ein Überbleibsel aus der ursprünglichen SQLite-Planung. Die Anwendung nutzt PostgreSQL.

View File

@@ -9,8 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.6" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.2" />
<PackageReference Include="MessagePack" Version="3.1.8" />
</ItemGroup>

View File

@@ -0,0 +1,212 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
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<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
private readonly AuthController _sut;
public AuthControllerTests()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Key"] = "TestKeyThatIsAtLeast32CharactersLong!!",
["Jwt:Issuer"] = "TestIssuer",
["Jwt:Audience"] = "TestAudience",
["Jwt:ExpirationMinutes"] = "60"
})
.Build();
_sut = new AuthController(_factory, config, new TestHostEnvironment());
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Development;
public string ApplicationName { get; set; } = "SlipItIn.Server.Tests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
// ---------- 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<OkObjectResult>(result.Result);
var dto = Assert.IsType<AuthResponseDto>(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<BadRequestObjectResult>(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<ConflictObjectResult>(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<ConflictObjectResult>(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<OkObjectResult>(result.Result);
var dto = Assert.IsType<AuthResponseDto>(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<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task Login_ReturnsUnauthorized_WhenUserNotFound()
{
var result = await _sut.Login(new LoginRequestDto { Email = "ghost@test.local", Password = "Secret123!" });
Assert.IsType<UnauthorizedObjectResult>(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<UnauthorizedObjectResult>(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<OkObjectResult>(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<UnauthorizedResult>(result.Result);
}
}

View File

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

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class GameHubTests
{
private readonly IDbContextFactory<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
private readonly Mock<IGameService> _gameService = new();
private readonly Mock<IGroupManager> _groups = new();
private readonly Mock<IHubCallerClients> _clients = new();
private readonly Mock<ISingleClientProxy> _callerProxy = new();
private readonly Mock<IClientProxy> _groupProxy = new();
private readonly Mock<ISingleClientProxy> _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<string>())).Returns(_groupProxy.Object);
_clients.Setup(c => c.Client(It.IsAny<string>())).Returns(_clientProxy.Object);
_hub = new GameHub(_gameService.Object, _factory, NullLogger<GameHub>.Instance);
SetupContext();
}
private void SetupContext()
{
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, _userId.ToString())], "TestAuth");
var context = new Mock<HubCallerContext>();
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<HubCallerContext>();
context.Setup(c => c.ConnectionId).Returns(ConnectionId);
context.Setup(c => c.User).Returns((ClaimsPrincipal?)null);
_hub.Context = context.Object;
}
private static string? ErrorMessageOf(Mock<ISingleClientProxy> 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<object?[]>(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<int>(), It.IsAny<string?>()))
.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<object?[]>(), 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<string>(), It.IsAny<int>(), It.IsAny<string?>()))
.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<string>(), It.IsAny<int>(), It.IsAny<string?>()))
.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<object?[]>(), 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<int>(), It.IsAny<int>()))
.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<int>())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid });
await _hub.StartGame(game.Id);
_groupProxy.Verify(p => p.SendCoreAsync("GameStarted", It.IsAny<object?[]>(), 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<object?[]>(), 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<int>())).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<object?[]>(), 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<int>(), It.IsAny<int>(), It.IsAny<int>()))
.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<object?[]>(), default), Times.Once);
_clients.Verify(c => c.Client("conn-target"), Times.Once);
_clientProxy.Verify(p => p.SendCoreAsync("ChallengeReceived", It.IsAny<object?[]>(), 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<int>(), It.IsAny<int>(), It.IsAny<int>()))
.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<int>())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid });
await _hub.ResolveChallenge(77, true);
_groupProxy.Verify(p => p.SendCoreAsync("ChallengeResolved", It.IsAny<object?[]>(), default), Times.Once);
_clientProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny<object?[]>(), 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<object?[]>(), 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<int>())).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);
}
}

View File

@@ -0,0 +1,556 @@
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<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
private readonly GameService _sut;
public GameServiceTests()
{
_sut = new GameService(_factory, NullLogger<GameService>.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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _sut.JoinGameAsync(game.LobbyCode, host.Id));
}
// ---------- StartGameAsync ----------
[Fact]
public async Task StartGameAsync_SetsStatusAndCreatesFirstRound()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db);
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 });
await db.SaveChangesAsync();
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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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, GameStatus.InProgress);
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()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db);
var (game, _) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SubmitSlipAsync(game.Id, 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, GameStatus.InProgress);
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<UnauthorizedAccessException>(() => _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, IsUsed = true };
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<InvalidOperationException>(() => _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<InvalidOperationException>(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id));
}
[Fact]
public async Task CreateChallengeAsync_Throws_WhenChallengerNotInGame()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var outsider = TestDbHelper.SeedUser(db, "outsider", "outsider@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, IsUsed = true };
db.PlayerCards.Add(card);
await db.SaveChangesAsync();
// outsider ist nicht in diesem Spiel und versucht eine Challenge anzulegen
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id + 1000, card.Id));
}
[Fact]
public async Task CreateChallengeAsync_Throws_WhenChallengerFromOtherGame()
{
using var db = _factory.CreateDbContext();
var hostA = TestDbHelper.SeedUser(db, "hostA", "hostA@t.local");
var hostB = TestDbHelper.SeedUser(db, "hostB", "hostB@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (gameA, hostPlayerA) = TestDbHelper.SeedGame(db, hostA);
var (gameB, _) = TestDbHelper.SeedGame(db, hostB);
var otherPlayerB = new Player { UserId = other.Id, GameId = gameB.Id };
db.Players.Add(otherPlayerB);
var roundB = new GameRound { GameId = gameB.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(roundB);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var cardB = new PlayerCard { PlayerId = otherPlayerB.Id, PhraseId = phrase.Id, GameRoundId = roundB.Id, IsUsed = true };
db.PlayerCards.Add(cardB);
await db.SaveChangesAsync();
// hostPlayerA gehört zu gameA, versucht aber eine Karte aus gameB zu challengen
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.CreateChallengeAsync(gameB.Id, hostPlayerA.Id, cardB.Id));
}
// ---------- ResolveChallengeAsync ----------
[Fact]
public async Task ResolveChallengeAsync_Approved_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, IsUsed = true };
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);
// Berechtigte Beschuldigung: 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_Rejected_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, IsUsed = true };
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);
// Falsche Beschuldigung: 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_Throws_WhenChallengeNotFound()
{
await Assert.ThrowsAsync<InvalidOperationException>(() => _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<InvalidOperationException>(() => _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, Status = RoundStatus.Active };
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<InvalidOperationException>(() => _sut.GetGameAsync(999));
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlipItIn.Server\SlipItIn.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Tests;
/// <summary>
/// Hilfsmethoden zum Erstellen isolierter InMemory-DbContexts und Testdaten.
/// </summary>
public static class TestDbHelper
{
public static IDbContextFactory<SlipItInDbContext> CreateFactory()
{
var options = new DbContextOptionsBuilder<SlipItInDbContext>()
.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<Phrase> 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;
}
/// <summary>Erstellt ein Spiel inkl. Host-Spieler über den echten GameService-Pfad (direkt in DB).</summary>
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<SlipItInDbContext> options) : IDbContextFactory<SlipItInDbContext>
{
public SlipItInDbContext CreateDbContext() => new(options);
public Task<SlipItInDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SlipItInDbContext(options));
}
}

View File

@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
{
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
{
_contextFactory = contextFactory;
_configuration = configuration;
_environment = environment;
}
[HttpPost("register")]
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest("Username, Email und Passwort sind erforderlich.");
if (request.Password.Length < 8)
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
using var context = _contextFactory.CreateDbContext();
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
return Ok(new
{
UserId = int.Parse(userIdClaim),
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
Username = username,
Email = email
});
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
private AuthResponseDto CreateAuthResponse(User user)
{
var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtKey = _configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey))
{
if (!_environment.IsDevelopment())
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
}
var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;

View File

@@ -142,6 +142,7 @@ public class GameHub : Hub
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
@@ -259,10 +260,16 @@ public class GameHub : Hub
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
var game = await _gameService.GetGameAsync(gameId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId,
Status = challenge.Status.ToString(),
@@ -272,7 +279,6 @@ public class GameHub : Hub
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler
using var context = _contextFactory.CreateDbContext();
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
if (targetPlayer?.ConnectionId != null)
{
@@ -372,9 +378,26 @@ public class GameHub : Hub
{
player.ConnectionId = Context.ConnectionId;
await context.SaveChangesAsync();
// (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen.
var lobbyCode = await context.Games
.Where(g => g.Id == player.GameId)
.Select(g => g.LobbyCode)
.FirstAsync();
await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode);
// Der Client bekommt nach einem Reconnect keinen vollständigen Zustand
// mehr aus der Queue gespielt — er muss RequestGameState aufrufen.
}
}
catch { /* Ignorieren falls nicht authentifiziert */ }
catch (UnauthorizedAccessException)
{
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
}
await base.OnConnectedAsync();
}
@@ -382,6 +405,23 @@ public class GameHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
// ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen.
try
{
using var context = _contextFactory.CreateDbContext();
var players = await context.Players
.Where(p => p.ConnectionId == Context.ConnectionId)
.ToListAsync();
foreach (var player in players)
player.ConnectionId = null;
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear connection id on disconnect");
}
await base.OnDisconnectedAsync(exception);
}
}
}

View File

@@ -14,13 +14,27 @@ builder.AddServiceDefaults();
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
// Services registrieren
builder.Services.AddTransient<IGameService, GameService>();
// JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtKey = builder.Configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
{
if (builder.Environment.IsDevelopment())
{
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
}
else
{
throw new InvalidOperationException(
"Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " +
"In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden.");
}
}
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
using System.Security.Cryptography;
namespace SlipItIn.Server.Services;
@@ -21,7 +22,7 @@ public class GameService : IGameService
using var context = _contextFactory.CreateDbContext();
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
var lobbyCode = GenerateLobbyCode();
var lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
var game = new Game
{
LobbyCode = lobbyCode,
@@ -70,7 +71,16 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
var game = await context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game has already started or ended");
if (game.Players.Count < 2)
throw new InvalidOperationException("At least 2 players are required to start the game");
game.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow;
@@ -85,12 +95,16 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game is not in lobby status");
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
?? throw new InvalidOperationException("Player not found in this game");
player.IsReady = true;
await context.SaveChangesAsync();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
return game;
}
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
@@ -108,7 +122,7 @@ public class GameService : IGameService
foreach (var player in players)
{
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5);
foreach (var phrase in selectedPhrases)
{
@@ -125,6 +139,7 @@ public class GameService : IGameService
}
currentRound.Status = RoundStatus.Active;
currentRound.StartedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return dealtCards;
@@ -134,10 +149,18 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.InProgress)
throw new InvalidOperationException("Game is not in progress");
var card = await context.PlayerCards
.Include(pc => pc.Player)
.Include(pc => pc.GameRound)
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
card.IsUsed = true;
await context.SaveChangesAsync();
@@ -149,13 +172,29 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var targetCard = await context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
var currentRound = await context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
if (currentRound == null) throw new InvalidOperationException("No active round found");
var challengerBelongsToGame = await context.Players
.AnyAsync(p => p.Id == challengingPlayerId && p.GameId == gameId);
if (!challengerBelongsToGame)
throw new UnauthorizedAccessException("Challenger does not belong to this game");
var targetCard = await context.PlayerCards
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
if (targetCard.GameRoundId != currentRound.Id)
throw new InvalidOperationException("Card does not belong to the active round");
if (targetCard.PlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge your own card");
if (!targetCard.IsUsed)
throw new InvalidOperationException("Card has not been played yet");
var pendingExists = await context.SlipChallenges
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
if (pendingExists)
throw new InvalidOperationException("A challenge for this card is already pending");
var challenge = new SlipChallenge
{
@@ -179,19 +218,30 @@ public class GameService : IGameService
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
// approved = Beschuldigung stimmt (Phrase war falsch)
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
if (challenge.Status != ChallengeStatus.Pending)
throw new InvalidOperationException("Challenge has already been resolved");
if (!approved)
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
if (approved)
{
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
var targetCard = challenge.TargetCard;
targetCard.PlayerId = challenge.ChallengingPlayerId;
context.PlayerCards.Update(targetCard);
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
challenge.ChallengingPlayer.Score += 1;
challenge.TargetPlayer.FailedSlips += 1;
}
else
{
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
challenge.TargetPlayer.SuccessfulSlips += 1;
challenge.TargetPlayer.Score += 1;
challenge.ChallengingPlayer.FailedSlips += 1;
}
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow;
@@ -218,7 +268,7 @@ public class GameService : IGameService
Username = p.User.Username,
Score = p.Score,
IsReady = p.IsReady,
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
}).ToList();
var currentRound = game.Rounds
@@ -249,9 +299,17 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
var activeRoundIds = await context.GameRounds
.Where(gr => gr.Status == RoundStatus.Active &&
gr.Game.Players.Any(p => p.Id == playerId))
.Select(gr => gr.Id)
.ToListAsync();
var cards = await context.PlayerCards
.Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId)
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
.Select(pc => new PlayerCardDto
{
CardId = pc.Id,
@@ -275,11 +333,25 @@ public class GameService : IGameService
?? throw new InvalidOperationException("Game not found");
}
private string GenerateLobbyCode()
private static string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
var buffer = new char[6];
for (var i = 0; i < buffer.Length; i++)
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
return new string(buffer);
}
return code;
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
var code = GenerateLobbyCode();
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
if (!exists)
return code;
}
throw new InvalidOperationException("Could not generate a unique lobby code");
}
}

View File

@@ -7,14 +7,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.4.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<PackageReference Include="Aspire.Npgsql" Version="13.5.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
</ItemGroup>

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
}
}

View File

@@ -7,11 +7,7 @@
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "SlipItInServer",
"Audience": "SlipItInClient",
"ExpirationMinutes": 1440

View File

@@ -10,13 +10,13 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
</ItemGroup>
</Project>

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
</ItemGroup>
</Project>

View File

@@ -1,13 +1,10 @@
<Solution>
<Folder Name="/Projektmappenelemente/">
<File Path="Agents/Architecture.md" />
<File Path="Agents/ProjectPlan.md" />
</Folder>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Project Path="SlipItIn/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

@@ -5,6 +5,8 @@ namespace SlipItIn;
public partial class App : Application
{
private readonly SemaphoreSlim _startupLock = new(1, 1);
public App()
{
InitializeComponent();
@@ -21,15 +23,25 @@ public partial class App : Application
window.Resumed += async (_, _) =>
{
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
try
{
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
}
catch
{
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
}
};
return window;
}
private static async Task InitializeSafeAsync()
private async Task InitializeSafeAsync()
{
if (!await _startupLock.WaitAsync(0))
return;
try
{
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
@@ -43,8 +55,14 @@ public partial class App : Application
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return;
var isSessionValid = await apiService.ValidateSessionAsync();
if (!isSessionValid)
var sessionResult = await apiService.ValidateSessionAsync();
if (sessionResult == SessionValidationResult.NetworkError)
{
// Netzwerkfehler darf die lokale Session nicht zerstören.
return;
}
if (sessionResult == SessionValidationResult.Invalid)
{
await authSession.ClearSessionAsync();
await gameStateService.ClearLocalGameDataAsync();
@@ -60,5 +78,9 @@ public partial class App : Application
{
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
}
finally
{
_startupLock.Release();
}
}
}

View File

@@ -5,4 +5,5 @@ public class QueuedGameAction
public string Method { get; set; } = string.Empty;
public string ArgumentsJson { get; set; } = "[]";
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; }
}

View File

@@ -28,20 +28,26 @@ public class ApiService : IApiService
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken);
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
public async Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
return false;
return SessionValidationResult.Invalid;
try
{
AttachAuthHeader();
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
return response.IsSuccessStatusCode;
return response.IsSuccessStatusCode
? SessionValidationResult.Valid
: SessionValidationResult.Invalid;
}
catch
catch (HttpRequestException)
{
return false;
return SessionValidationResult.NetworkError;
}
catch (TaskCanceledException)
{
return SessionValidationResult.NetworkError;
}
}

View File

@@ -28,21 +28,21 @@ public class GameStateService : IGameStateService
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
_signalR.GameStateUpdated += async (_, state) =>
_signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
{
CurrentGameState = state;
await _localStorage.SaveGameStateAsync(state);
_messenger.Send(new GameStateChangedMessage(state));
};
});
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
_signalR.PlayerHandUpdated += async (_, hand) =>
_signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
{
CurrentPlayerHand = hand;
await _localStorage.SavePlayerHandAsync(hand);
_messenger.Send(new PlayerHandChangedMessage(hand));
};
});
_signalR.ChallengeReceived += (_, challenge) =>
{
@@ -51,21 +51,26 @@ public class GameStateService : IGameStateService
};
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += async (_, connected) =>
_signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
{
_messenger.Send(new ConnectionStateChangedMessage(connected));
if (!connected)
return;
try
{
await ResyncAsync();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
}
};
await ResyncAsync();
});
}
private async void RunSafeBridge(Func<Task> action)
{
try
{
await action();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Hintergrundfehler: {ex.Message}"));
}
}
public async Task InitializeAsync()
@@ -153,12 +158,16 @@ public class GameStateService : IGameStateService
PublishSyncState(false, "Bereit", 0);
}
private const int MaxQueueRetries = 3;
private async Task FlushQueuedActionsCoreAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
var poisoned = new List<QueuedGameAction>();
foreach (var action in snapshot)
{
try
@@ -169,11 +178,23 @@ public class GameStateService : IGameStateService
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break;
action.RetryCount++;
if (action.RetryCount >= MaxQueueRetries)
{
poisoned.Add(action);
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion verworfen ({action.Method}): {ex.Message}"));
}
else
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break; // Bei transienten Fehlern den Rest der Queue nicht blockieren
}
}
}
foreach (var item in poisoned)
_queuedActions.Remove(item);
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}

View File

@@ -2,10 +2,17 @@ using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public enum SessionValidationResult
{
Valid,
Invalid,
NetworkError
}
public interface IApiService
{
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task LogoutAsync();
}

View File

@@ -1,21 +1,25 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class SignalRService : ISignalRService
public class SignalRService : ISignalRService, IAsyncDisposable
{
private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession;
private readonly IServiceProvider _serviceProvider;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private HubConnection? _hubConnection;
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
{
_configuration = configuration;
_authSession = authSession;
_serviceProvider = serviceProvider;
}
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
@@ -33,36 +37,76 @@ public class SignalRService : ISignalRService
if (IsConnected)
return true;
EnsureConnection(token);
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
await _connectLock.WaitAsync(cancellationToken);
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
if (IsConnected)
return true;
// Bestehende (möglicherweise tote) Verbindung vollständig entsorgen,
// damit der AccessTokenProvider keinen veralteten Token-Closure verwendet.
if (_hubConnection is not null)
{
try
{
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync(cancellationToken);
}
catch
{
// Best effort; Dispose folgt trotzdem.
}
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
EnsureConnection(token);
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
}
finally
{
_connectLock.Release();
}
}
public async Task DisconnectAsync()
{
if (_hubConnection is null)
return;
await _connectLock.WaitAsync();
try
{
if (_hubConnection is null)
return;
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
await _hubConnection.DisposeAsync();
_hubConnection = null;
ConnectionStateChanged?.Invoke(this, false);
}
finally
{
_connectLock.Release();
}
}
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
@@ -87,7 +131,7 @@ public class SignalRService : ISignalRService
public async Task SendQueuedActionAsync(QueuedGameAction action)
{
if (_hubConnection is null)
return;
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
object?[] args;
try
@@ -95,11 +139,11 @@ public class SignalRService : ISignalRService
var doc = JsonDocument.Parse(action.ArgumentsJson);
args = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
: [];
: throw new InvalidOperationException($"Ungültige Queue-Aktion: {action.ArgumentsJson}");
}
catch
catch (JsonException ex)
{
args = [];
throw new InvalidOperationException($"Queue-Aktion '{action.Method}' ist beschädigt.", ex);
}
await InvokeAsync(action.Method, args);
@@ -113,7 +157,8 @@ public class SignalRService : ISignalRService
_hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(_authSession.AccessToken ?? token);
// Token immer zur Laufzeit aus der Session lesen — nie den Startwert cachen.
options.AccessTokenProvider = () => Task.FromResult(_authSession.AccessToken);
})
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build();
@@ -155,6 +200,13 @@ public class SignalRService : ISignalRService
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
// Server-Broadcasts nach Slip/Challenge/Resolve, damit die UI auch ohne
// vollständiges GameState-Update reagieren kann.
_hubConnection.On<JsonElement>("SlipSubmitted", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("SlipChallenged", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("ChallengeResolved", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("CardsDealt", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("Error", payload =>
{
var message = payload.TryGetProperty("message", out var msgElement)
@@ -164,6 +216,26 @@ public class SignalRService : ISignalRService
});
}
private void RequestCurrentGameStateSafe()
{
_ = Task.Run(async () =>
{
try
{
// Lazy über ServiceProvider auflösen, um eine zirkuläre Abhängigkeit
// SignalRService ↔ GameStateService zu vermeiden.
var gameStateService = _serviceProvider.GetService<IGameStateService>();
var gameState = gameStateService?.CurrentGameState;
if (gameState?.GameId > 0 && IsConnected)
await RequestGameStateAsync(gameState.GameId);
}
catch
{
// Hintergrund-Refresh darf nicht abstürzen.
}
});
}
private async Task InvokeAsync(string method, params object?[] args)
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
@@ -186,4 +258,10 @@ public class SignalRService : ISignalRService
_ => element.GetRawText()
};
}
public async ValueTask DisposeAsync()
{
if (_hubConnection is not null)
await _hubConnection.DisposeAsync();
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
@@ -48,35 +48,11 @@
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
@@ -89,7 +65,6 @@
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
@@ -99,10 +74,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.100" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
@@ -110,9 +85,6 @@
</ItemGroup>
<ItemGroup>
<MauiXaml Update="MainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>

View File

@@ -19,6 +19,8 @@ public partial class GameBoardViewModel : BaseViewModel,
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
private readonly IAuthSessionService _authSession;
private readonly IDispatcherTimer? _roundTimer;
private DateTime _lastStateUtc = DateTime.UtcNow;
[ObservableProperty]
private int gameId;
@@ -58,6 +60,26 @@ public partial class GameBoardViewModel : BaseViewModel,
IsConnected = _signalR.IsConnected;
IsActive = true;
if (Application.Current?.Dispatcher is { } dispatcher)
{
_roundTimer = dispatcher.CreateTimer();
_roundTimer.Interval = TimeSpan.FromSeconds(1);
_roundTimer.Tick += OnRoundTimerTick;
}
}
private void OnRoundTimerTick(object? sender, EventArgs e)
{
if (RoundTimeRemaining <= 0)
return;
var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds;
if (elapsed > 0)
{
RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed);
_lastStateUtc = DateTime.UtcNow;
}
}
[RelayCommand]
@@ -123,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null;
IsChallengeVisible = false;
await RunSafeAsync(async () =>
{
if (_signalR.IsConnected)
{
await _signalR.ResolveChallengeAsync(challengeId, true);
}
else
{
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true);
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
}
});
}
[RelayCommand]
@@ -134,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null;
IsChallengeVisible = false;
await RunSafeAsync(async () =>
{
if (_signalR.IsConnected)
{
await _signalR.ResolveChallengeAsync(challengeId, false);
}
else
{
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, false);
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
}
});
}
public void Receive(GameStateChangedMessage message)
@@ -146,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
GameId = message.Value.GameId;
CurrentRound = message.Value.CurrentRound;
RoundTimeRemaining = message.Value.RoundTimeRemaining;
_lastStateUtc = DateTime.UtcNow;
var me = _authSession.CurrentUser?.Username;
var others = message.Value.Players
@@ -170,27 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
public void Receive(ChallengeReceivedMessage message)
{
PendingChallenge = message.Value;
IsChallengeVisible = true;
MainThread.BeginInvokeOnMainThread(() =>
{
PendingChallenge = message.Value;
IsChallengeVisible = true;
});
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
}
public void StartRoundTimer() => _roundTimer?.Start();
public void StopRoundTimer() => _roundTimer?.Stop();
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;

View File

@@ -55,6 +55,16 @@ public partial class LobbyViewModel : BaseViewModel,
IsActive = true;
}
/// <summary>
/// Reaktiviert das ViewModel nach einem Logout (Singleton-Lebensdauer).
/// Wird von LobbyPage.OnNavigatedTo aufgerufen.
/// </summary>
public void Activate()
{
IsConnected = _signalR.IsConnected;
IsActive = true;
}
[RelayCommand]
private async Task CreateLobbyAsync()
{
@@ -105,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
{
await RunSafeAsync(async () =>
{
IsActive = false;
await _signalR.DisconnectAsync();
await _apiService.LogoutAsync();
await _gameStateService.ClearLocalGameDataAsync();
@@ -134,26 +145,39 @@ public partial class LobbyViewModel : BaseViewModel,
});
}
public async void Receive(GameStartedMessage message)
public void Receive(GameStartedMessage message)
{
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage)));
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
Shell.Current.GoToAsync(nameof(Views.GameBoardPage));
}
catch (Exception ex)
{
StatusMessage = $"Navigation fehlgeschlagen: {ex.Message}";
}
});
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
private int GetCurrentPlayerId()

View File

@@ -58,8 +58,11 @@ public partial class LoginViewModel : BaseViewModel
if (!_authSession.IsAuthenticated)
return;
var isSessionValid = await _apiService.ValidateSessionAsync();
if (!isSessionValid)
var sessionResult = await _apiService.ValidateSessionAsync();
if (sessionResult == SessionValidationResult.NetworkError)
return;
if (sessionResult == SessionValidationResult.Invalid)
{
await _authSession.ClearSessionAsync();
await _gameStateService.ClearLocalGameDataAsync();

View File

@@ -44,7 +44,9 @@ public partial class RegisterViewModel : BaseViewModel
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
var connected = await _signalR.ConnectAsync(response.Token);
if (!connected)
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Registrierung läuft...");
}

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay">
xmlns:vm="clr-namespace:SlipItIn.ViewModels"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
x:DataType="vm:GameBoardViewModel">
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
<Frame VerticalOptions="Center" HorizontalOptions="Center">
<Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
<VerticalStackLayout Spacing="10">
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
@@ -12,6 +14,6 @@
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Border>
</Grid>
</ContentView>

View File

@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
public partial class GameBoardPage : ContentPage
{
private readonly GameBoardViewModel _viewModel;
public GameBoardPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>();
BindingContext = _viewModel = ServiceHelper.GetRequiredService<GameBoardViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.StartRoundTimer();
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
{
base.OnNavigatedFrom(args);
_viewModel.StopRoundTimer();
}
}

View File

@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
public partial class LobbyPage : ContentPage
{
private readonly LobbyViewModel _viewModel;
public LobbyPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>();
BindingContext = _viewModel = ServiceHelper.GetRequiredService<LobbyViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.Activate();
}
}

View File

@@ -1,5 +1,5 @@
{
"appHost": {
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
}
}