Compare commits

19 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
Tim Krampitz
dede059983 Removed obsolete documentation and planning. 2026-08-22 19:08:33 +02:00
Tim Krampitz
8078ac2212 Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-22 19:07:15 +02:00
Tim Krampitz
540cda102f Support Android emulator API access
Enable CORS in all environments and disable HTTPS redirect in dev.
Seed the database after migrations.
Make the API base URL configurable, defaulting to the Android
emulator host, and use per-config Android manifests.
2026-08-22 19:05:53 +02:00
tim
bf306a6c15 Merge pull request 'openwiki-update.yml: Actions & Schutzmechanismus verbessert' (#1) from openwiki/update into master
Some checks failed
OpenWiki Update / update (push) Has been cancelled
Reviewed-on: #1
2026-08-09 15:24:31 +00:00
Tim Krampitz
3296ecdfb3 JWT- und Session-Validierung verbessert, UI aktualisiert
- JWT-Authentifizierung prüft nun, ob User aktiv und existent ist
- Session-Validierung beim App-Start via ValidateSessionAsync
- Ungültige Sessions werden entfernt, SignalR-Verbindung getrennt
- ApiService und IApiService um ValidateSessionAsync erweitert
- LoginViewModel nutzt Session-Check und behandelt SignalR-Fehler
- UI für eigene Phrasen auf <Border> mit Stil-Anpassung umgestellt
2026-08-08 21:30:18 +02:00
Tim Krampitz
6f3c0eb716 Synchronisierungs- und Verbindungsstatus verbessert
Neuer Nachrichtentyp SyncStateChangedMessage zur Übermittlung des Sync-Status über das MVVM-Messaging-System eingeführt. GameStateService sendet nach relevanten Aktionen den aktuellen Sync-Status. Methoden zur Synchronisierung sind jetzt thread-sicher und melden Statusänderungen laufend. Fehler beim Senden von Queue-Aktionen werden einzeln behandelt und führen zu Fehlermeldungen im Messenger. ViewModels aktualisieren neue Properties (IsSyncing, SyncStatusText, PendingSyncActions) bei Statusänderungen. XAML-Seiten zeigen Verbindungs- und Sync-Status sowie Offline-Aktionen an. SignalRService meldet Verbindungsstatus konsistenter, auch bei Fehlern und Disconnects.
2026-08-08 21:23:27 +02:00
Tim Krampitz
0f9cc1703a openwiki-update.yml: Actions & Schutzmechanismus verbessert
- Actions checkout und setup-node auf neuere Versionen aktualisiert
- fetch-depth: 0 für vollständige Git-Historie gesetzt
- npm-Installationsbefehl für openwiki vereinfacht (immer neueste Version)
- Schritt „Restore protected workflow file“ hinzugefügt, um Überschreiben durch OpenWiki zu verhindern
- Kleine Anpassung im PR-Body (Leerzeile entfernt)
2026-08-08 21:19:43 +02:00
Tim Krampitz
936c8bde28 openwiki-update.yml: Actions & Schutzmechanismus verbessert
All checks were successful
OpenWiki Update / update (push) Successful in 2m21s
- Actions checkout und setup-node auf neuere Versionen aktualisiert
- fetch-depth: 0 für vollständige Git-Historie gesetzt
- npm-Installationsbefehl für openwiki vereinfacht (immer neueste Version)
- Schritt „Restore protected workflow file“ hinzugefügt, um Überschreiben durch OpenWiki zu verhindern
- Kleine Anpassung im PR-Body (Leerzeile entfernt)
2026-08-08 21:15:23 +02:00
Tim Krampitz
2ce9bcd4c4 Update openwiki-update.yml 2026-08-08 21:07:50 +02:00
Tim Krampitz
18a9d1ab74 Robustere Initialisierung & Routing-Refaktor
- InitializeAsync in InitializeSafeAsync mit Fehlerbehandlung umbenannt, App-Start robuster gestaltet
- Asynchrone Initialisierung bei Window.Created
- Navigation vereinheitlicht: Routing.RegisterRoute statt Doppelslash, Navigation ohne Präfix
- Flyout-Menü in AppShell deaktiviert, ShellContent-Einträge entfernt
- Login/Register-ViewModels nutzen private Felder statt ObservableProperty
- SignalRService nutzt AccessToken aus AuthSessionService
- GameStateService: Automatischer Resync bei Verbindung, Fehlerhandling verbessert
- Navigation in ViewModels an neues Routing angepasst
- Aufräumarbeiten: Entfernte auskommentierte Messenger-Registrierungen, Initialisierung vereinheitlicht
2026-08-08 13:12:57 +02:00
49 changed files with 2511 additions and 1404 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

View File

@@ -49,7 +49,7 @@ jobs:
run: git checkout -- .github/workflows/openwiki-update.yml
- name: Create OpenWiki update pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: infinilabs/gitea-pr@v1
with:
add-paths: |
openwiki
@@ -58,6 +58,10 @@ jobs:
.github/workflows/openwiki-update.yml
branch: openwiki/update
commit-message: "docs: update OpenWiki"
url: ${{ secrets.URL }}
token: ${{ secrets.TOKEN }}
base_branch: master
head_branch: openwiki/update
title: "docs: update OpenWiki"
body: |
Automated OpenWiki documentation update.

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,136 +0,0 @@
# Slip It In Architektur-Updates (Phase 2b & 3b)
Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden.
## 🔒 Backend-Sicherheit (Phase 2b)
### 1. JWT-Claims Validation im GameHub
- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing)
- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims
- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)`
### 2. IDbContextFactory statt Scoped DbContext
- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen
- **Lösung**: `IDbContextFactory<SlipItInDbContext>` erzeugt isolierte Sessions pro Aufruf
- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges
### 3. Serverseitige Validierung
- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES
- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server:
- ✅ Gehört die Phrase dem Spieler?
- ✅ Ist die Runde aktiv?
- ✅ Ist der Spieler noch im Spiel?
### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto)
- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar
- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst
- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe!
---
## 📱 Client-Stabilität (Phase 3b)
### 1. WeakReferenceMessenger für Event-Entkopplung
- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed
- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()`
- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten
**Beispiel:**
```csharp
// Service sendet Event
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState));
// ViewModel empfängt (mit auto-cleanup beim Dispose)
WeakReferenceMessenger.Default.Register<GameStateChangedMessage>(this, (r, m) =>
{
CurrentGameState = m.Value;
});
```
### 2. Automatischer Reconnect
- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s)
- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben
- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen
**Flow:**
1. SignalR-Verbindung getrennt
2. Client speichert "Slip zu Phrase X" in lokale Queue
3. Nach 2s Automatischer Reconnect-Versuch
4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet
### 3. State Restoration
- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage`
- **App-Resume**: `ResyncAsync()` wird aufgerufen
- ✅ Holt aktuellen GameState vom Server
- ✅ Sendet gepufferte Offline-Aktionen erneut
- ✅ UI zeigt "Syncing..." bis fertig
### 4. Dual-Layer Storage
- **In-Memory Layer**: `GameStateService` halten aktuellen State
- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup
- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden
---
## 📊 Schnellreferenz: Neue Components
### Backend
```
GameHub
├─ GetAuthenticatedUserId() → JWT-Validierung
├─ ValidatePlayerAccessAsync() → Authorization Check
└─ Alle Methoden nutzen IDbContextFactory
Program.cs
├─ AddDbContextFactory<SlipItInDbContext>()
├─ AddAuthentication(JwtBearerDefaults)
└─ AddSignalR() mit JWT-Filter
DTOs
├─ GameStateDto (öffentlich)
├─ PlayerHandDto (privat)
└─ Keine CardText in GameStateDto!
```
### Client
```
GameStateService
├─ CurrentGameState (in-memory)
├─ CurrentPlayerHand (in-memory)
├─ UpdateGameStateAsync() → speichert + sendet Message
└─ ResyncAsync() → bei App-Resume
SignalRService
├─ ConnectAsync(token) mit Auto-Reconnect
├─ RequestGameStateAsync()
└─ On<> Handler senden Messages
Messages (WeakReferenceMessenger)
├─ GameStateChangedMessage
├─ PlayerHandChangedMessage
└─ ChallengeReceivedMessage
```
---
## 🧪 Wichtigste Tests
| Test | Ziel |
|------|------|
| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab |
| **Player-Access** | User A kann nicht auf Players von User B zugreifen |
| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges |
| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet |
| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) |
| **State-Sync** | App-Resume synct korrekt mit Server-State |
---
## 🚀 Implementierungs-Reihenfolge
1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren
2. **GameService erweitern**: Serverseitige Validierung in alle Methoden
3. **Client-Services**: GameStateService + SignalRService implementieren
4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren
5. **Tests**: Integrationstest-Suite schreiben
6. **UI**: Views an neue Message-Events binden

File diff suppressed because it is too large Load Diff

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

@@ -1,8 +1,7 @@
var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddPostgres("pgsql")
.AddDatabase("postgresdb")
;
.AddDatabase("postgresdb");
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
.WithReference(db)

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

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Data;
/// <summary>
/// Befüllt die Datenbank mit Testdaten (2 Testuser, einige Dutzend Phrasen),
/// wenn sie leer ist. Dient der Erleichterung manueller Tests.
/// </summary>
public static class DbSeeder
{
public static async Task SeedAsync(SlipItInDbContext context)
{
// Nur seeden, wenn die Datenbank leer ist
if (await context.Users.AnyAsync() || await context.Phrases.AnyAsync())
return;
var alice = new User
{
Username = "alice",
Email = "alice@test.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
};
var bob = new User
{
Username = "bob",
Email = "bob@test.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
};
context.Users.AddRange(alice, bob);
await context.SaveChangesAsync();
var phrases = new[]
{
"Das hat mir meine Oma schon immer gesagt.",
"Ich hab da mal eine Doku gesehen.",
"Das ist wie damals in der Grundschule.",
"Das muss man sich mal auf der Zunge zergehen lassen.",
"Da kommt mir direkt ein Lied in den Sinn.",
"Das ist wie beim Autofahren: Bremsen kann man immer noch.",
"Ich habe das Gefühl, wir reden aneinander vorbei.",
"Das würde ich an deiner Stelle nochmal überdenken.",
"Ich glaube, da steckt mehr dahinter.",
"Also ich finde, das riecht verdächtig nach Montag.",
"Das schmeckt nach mehr.",
"Da ist der Wurm drin.",
"Das hat hand und Fuß.",
"Das ist ein zweischneidiges Schwert.",
"Da liegt der Hase im Pfeffer.",
"Das ist wie ein guter Wein wird mit den Jahren besser.",
"Ich würde sagen: Glück im Unglück.",
"Das ist schon fast eine Wissenschaft für sich.",
"Man muss das Kind beim Namen nennen.",
"Das ist wie Weihnachten und Geburtstag zusammen.",
"Da geht mir das Herz auf.",
"Ich würde das jetzt nicht überbewerten.",
"Das kommt mir spanisch vor.",
"Das ist das Beste seit geschnittenem Brot.",
"Da bleibt kein Auge trocken.",
"Das ist wie Nägel mit Köpfen machen.",
"Ich habe da so eine Vermutung.",
"Das ist wie ein roter Faden.",
"Da platzt mir gleich der Kragen.",
"Ich bin da ganz bei dir.",
"Das muss man erstmal sacken lassen.",
"Das ist wie ein offenes Buch.",
"Da würde ich nicht drauf wetten.",
"Das ist wie eine Achterbahn der Gefühle.",
"Ich hab da ein ganz mieses Gefühl bei.",
"Das ist wie ein Dorn im Auge."
};
var random = new Random(42);
var creators = new[] { alice, bob };
foreach (var text in phrases)
{
context.Phrases.Add(new Phrase
{
Text = text,
CreatorId = creators[random.Next(creators.Length)].Id
});
}
await context.SaveChangesAsync();
}
}

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

@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using SlipItIn.Server.Data;
using SlipItIn.Server.Hubs;
using SlipItIn.Server.Services;
using System.Security.Claims;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
@@ -13,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";
@@ -50,6 +65,24 @@ builder.Services
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var userIdClaim = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(userIdClaim, out var userId))
{
context.Fail("Invalid user claim.");
return;
}
var dbFactory = context.HttpContext.RequestServices.GetRequiredService<IDbContextFactory<SlipItInDbContext>>();
await using var db = await dbFactory.CreateDbContextAsync();
var userExists = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive);
if (!userExists)
{
context.Fail("User no longer exists or is inactive.");
}
}
};
});
@@ -79,21 +112,28 @@ var app = builder.Build();
// Aspire Default Endpoints (Health Checks)
app.MapDefaultEndpoints();
// Migrations anwenden
// Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
db.Database.Migrate();
await DbSeeder.SeedAsync(db);
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseCors("AllowAll");
}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
// Beim Emulator/geräteübergreifenden Zugriff würde die HTTPS-Umleitung
// den Client auf eine nicht vertrauenswürdige Dev-Zertifikats-URL schicken.
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseAuthentication();
app.UseAuthorization();

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,37 +5,82 @@ namespace SlipItIn;
public partial class App : Application
{
private readonly SemaphoreSlim _startupLock = new(1, 1);
public App()
{
InitializeComponent();
_ = InitializeAsync();
}
protected override Window CreateWindow(IActivationState? activationState)
{
var window = new Window(new AppShell());
window.Created += async (_, _) =>
{
await InitializeSafeAsync();
};
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 InitializeAsync()
private async Task InitializeSafeAsync()
{
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
if (!await _startupLock.WaitAsync(0))
return;
await authSession.InitializeAsync();
await gameStateService.InitializeAsync();
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
try
{
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
var apiService = ServiceHelper.GetRequiredService<IApiService>();
await authSession.InitializeAsync();
await gameStateService.InitializeAsync();
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return;
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();
await signalR.DisconnectAsync();
return;
}
var connected = await signalR.ConnectAsync(authSession.AccessToken);
if (connected)
await gameStateService.ResyncAsync();
}
catch
{
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
}
finally
{
_startupLock.Release();
}
}
}

View File

@@ -3,22 +3,12 @@
x:Class="SlipItIn.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:SlipItIn.Views">
xmlns:views="clr-namespace:SlipItIn.Views"
FlyoutBehavior="Disabled">
<ShellContent
Route="LoginPage"
<ShellContent
Title="Login"
Route="LoginPage"
ContentTemplate="{DataTemplate views:LoginPage}" />
<ShellContent
Route="RegisterPage"
ContentTemplate="{DataTemplate views:RegisterPage}" />
<ShellContent
Route="LobbyPage"
ContentTemplate="{DataTemplate views:LobbyPage}" />
<ShellContent
Route="GameBoardPage"
ContentTemplate="{DataTemplate views:GameBoardPage}" />
</Shell>

View File

@@ -7,5 +7,8 @@ public partial class AppShell : Shell
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(RegisterPage), typeof(RegisterPage));
Routing.RegisterRoute(nameof(LobbyPage), typeof(LobbyPage));
Routing.RegisterRoute(nameof(GameBoardPage), typeof(GameBoardPage));
}
}

View File

@@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public sealed record SyncStateInfo(bool IsSyncing, string StatusText, int PendingActions);
public class SyncStateChangedMessage : ValueChangedMessage<SyncStateInfo>
{
public SyncStateChangedMessage(SyncStateInfo value) : base(value)
{
}
}

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

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application
android:allowBackup="true"
android:icon="@mipmap/appicon"
android:supportsRtl="true"
android:label="Slip It In"
android:usesCleartextTraffic="true" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application
android:allowBackup="true"
android:icon="@mipmap/appicon"
android:supportsRtl="true"
android:label="Slip It In">
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true" android:label="Slip It In"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

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

View File

@@ -4,9 +4,20 @@ namespace SlipItIn.Services;
public class AppConfigurationService : IAppConfigurationService
{
private const string ApiBaseUrlPreferenceKey = "api_base_url";
private const string DefaultApiBaseUrl = "https://localhost:7274";
private const string AndroidEmulatorApiBaseUrl = "http://10.0.2.2:5234";
public string ApiBaseUrl => DefaultApiBaseUrl;
public string ApiBaseUrl => Preferences.Default
.Get(ApiBaseUrlPreferenceKey, GetPlatformDefaultApiBaseUrl())
.TrimEnd('/');
public string HubUrl => $"{DefaultApiBaseUrl.TrimEnd('/')}/hubs/game";
public string HubUrl => $"{ApiBaseUrl}/hubs/game";
private static string GetPlatformDefaultApiBaseUrl()
{
return DeviceInfo.Platform == DevicePlatform.Android
? AndroidEmulatorApiBaseUrl
: DefaultApiBaseUrl;
}
}

View File

@@ -12,6 +12,7 @@ public class GameStateService : IGameStateService
private readonly ISignalRService _signalR;
private readonly ILocalStorageService _localStorage;
private readonly IMessenger _messenger;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private readonly List<QueuedGameAction> _queuedActions = [];
@@ -27,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) =>
{
@@ -50,7 +51,26 @@ public class GameStateService : IGameStateService
};
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += (_, connected) => _messenger.Send(new ConnectionStateChangedMessage(connected));
_signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
{
_messenger.Send(new ConnectionStateChangedMessage(connected));
if (!connected)
return;
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()
@@ -67,14 +87,32 @@ public class GameStateService : IGameStateService
if (CurrentPlayerHand is not null)
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
PublishSyncState(false, "Bereit", _queuedActions.Count);
}
public async Task ResyncAsync()
{
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await _syncLock.WaitAsync();
try
{
PublishSyncState(true, "Syncing...", _queuedActions.Count);
await FlushQueuedActionsAsync();
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Synchronisiert", _queuedActions.Count);
}
catch
{
PublishSyncState(false, "Sync fehlgeschlagen", _queuedActions.Count);
throw;
}
finally
{
_syncLock.Release();
}
}
public async Task EnqueueActionAsync(string method, params object[] arguments)
@@ -88,21 +126,22 @@ public class GameStateService : IGameStateService
_queuedActions.Add(action);
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
}
public async Task FlushQueuedActionsAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
await _syncLock.WaitAsync();
try
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
}
finally
{
_syncLock.Release();
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
public async Task ClearLocalGameDataAsync()
@@ -115,5 +154,52 @@ public class GameStateService : IGameStateService
await _localStorage.ClearGameStateAsync();
await _localStorage.ClearPlayerHandAsync();
await _localStorage.ClearQueuedActionsAsync();
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
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
}
catch (Exception ex)
{
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);
}
private void PublishSyncState(bool isSyncing, string statusText, int pendingActions)
{
_messenger.Send(new SyncStateChangedMessage(new SyncStateInfo(isSyncing, statusText, pendingActions)));
}
}

View File

@@ -2,9 +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<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,31 +37,76 @@ public class SignalRService : ISignalRService
if (IsConnected)
return true;
EnsureConnection(token);
if (_hubConnection is null)
return false;
await _connectLock.WaitAsync(cancellationToken);
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
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);
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;
}
}
catch (Exception ex)
finally
{
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
_connectLock.Release();
}
}
public async Task DisconnectAsync()
{
if (_hubConnection is null)
return;
await _connectLock.WaitAsync();
try
{
if (_hubConnection is null)
return;
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
await _hubConnection.DisposeAsync();
_hubConnection = null;
ConnectionStateChanged?.Invoke(this, false);
}
finally
{
_connectLock.Release();
}
}
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
@@ -82,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
@@ -90,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);
@@ -108,11 +157,18 @@ public class SignalRService : ISignalRService
_hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(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();
_hubConnection.Reconnecting += _ =>
{
ConnectionStateChanged?.Invoke(this, false);
return Task.CompletedTask;
};
_hubConnection.Closed += _ =>
{
ConnectionStateChanged?.Invoke(this, false);
@@ -144,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)
@@ -153,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)
@@ -175,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>
@@ -121,4 +96,12 @@
</MauiXaml>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Debug|net10.0-android'">
<AndroidManifest>Platforms\Android\AndroidManifest.Debug.xml</AndroidManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net10.0-android'">
<AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest>
</PropertyGroup>
</Project>

View File

@@ -12,11 +12,15 @@ public partial class GameBoardViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>,
IRecipient<PlayerHandChangedMessage>,
IRecipient<ChallengeReceivedMessage>,
IRecipient<ErrorOccurredMessage>
IRecipient<ErrorOccurredMessage>,
IRecipient<SyncStateChangedMessage>,
IRecipient<ConnectionStateChangedMessage>
{
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;
@@ -33,6 +37,18 @@ public partial class GameBoardViewModel : BaseViewModel,
[ObservableProperty]
private SlipChallengeDto? pendingChallenge;
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
@@ -42,8 +58,28 @@ public partial class GameBoardViewModel : BaseViewModel,
_gameStateService = gameStateService;
_authSession = authSession;
WeakReferenceMessenger.Default.RegisterAll(this);
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]
@@ -109,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]
@@ -120,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)
@@ -132,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
@@ -156,15 +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)
{
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
public void Receive(ConnectionStateChangedMessage message)
{
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

@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
IRecipient<LobbyCreatedMessage>,
IRecipient<GameStartedMessage>,
IRecipient<ErrorOccurredMessage>,
IRecipient<ConnectionStateChangedMessage>
IRecipient<ConnectionStateChangedMessage>,
IRecipient<SyncStateChangedMessage>
{
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
@@ -42,7 +52,16 @@ public partial class LobbyViewModel : BaseViewModel,
_apiService = apiService;
IsConnected = _signalR.IsConnected;
//WeakReferenceMessenger.Default.RegisterAll(this);
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;
}
@@ -96,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
{
await RunSafeAsync(async () =>
{
IsActive = false;
await _signalR.DisconnectAsync();
await _apiService.LogoutAsync();
await _gameStateService.ClearLocalGameDataAsync();
@@ -125,19 +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)
{
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
private int GetCurrentPlayerId()

View File

@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
private readonly IGameStateService _gameStateService;
[ObservableProperty]
public partial string Email { get; set; }
private string email = string.Empty;
[ObservableProperty]
public partial string Password { get; set; }
private string password = string.Empty;
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
{
@@ -28,7 +28,7 @@ public partial class LoginViewModel : BaseViewModel
}
[RelayCommand]
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}");
private Task GoToRegisterAsync() => Shell.Current.GoToAsync(nameof(RegisterPage));
[RelayCommand]
private async Task LoginAsync()
@@ -42,20 +42,40 @@ public partial class LoginViewModel : 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 _gameStateService.InitializeAsync();
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Anmeldung läuft...");
}
[RelayCommand]
public async Task InitializeAsync()
{
bool isAuthenticated = _authSession.IsAuthenticated;
if (!_authSession.IsAuthenticated)
return;
if ( isAuthenticated)
var sessionResult = await _apiService.ValidateSessionAsync();
if (sessionResult == SessionValidationResult.NetworkError)
return;
if (sessionResult == SessionValidationResult.Invalid)
{
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
await _authSession.ClearSessionAsync();
await _gameStateService.ClearLocalGameDataAsync();
await _signalR.DisconnectAsync();
return;
}
var token = _authSession.AccessToken;
if (string.IsNullOrWhiteSpace(token))
return;
var connected = await _signalR.ConnectAsync(token);
if (connected)
await Shell.Current.GoToAsync(nameof(LobbyPage));
}
}

View File

@@ -13,13 +13,13 @@ public partial class RegisterViewModel : BaseViewModel
private readonly ISignalRService _signalR;
[ObservableProperty]
public partial string Username { get; set; } = string.Empty;
private string username = string.Empty;
[ObservableProperty]
public partial string Email { get; set; } = string.Empty;
private string email = string.Empty;
[ObservableProperty]
public partial string Password { get; set; } = string.Empty;
private string password = string.Empty;
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
{
@@ -44,8 +44,10 @@ public partial class RegisterViewModel : BaseViewModel
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
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

@@ -10,6 +10,9 @@
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" TextColor="Gray" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />
@@ -17,7 +20,7 @@
<CollectionView ItemsSource="{Binding MyCards}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Margin="0,4" Padding="10">
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">
<VerticalStackLayout>
<Label Text="{Binding Text}" />
<HorizontalStackLayout>
@@ -25,7 +28,7 @@
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

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

@@ -7,6 +7,8 @@
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />

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"
}
}