Compare commits

..

14 Commits

Author SHA1 Message Date
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
37 changed files with 1906 additions and 1298 deletions

View File

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

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

10
SlipItIN.slnx Normal file
View File

@@ -0,0 +1,10 @@
<Solution>
<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.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

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

View File

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

View File

@@ -0,0 +1,203 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using SlipItIn.Server.Controllers;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace SlipItIn.Server.Tests;
public class AuthControllerTests
{
private readonly IDbContextFactory<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);
}
// ---------- 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,504 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using SlipItIn.Server.Data;
using SlipItIn.Server.Services;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Tests;
public class GameServiceTests
{
private readonly IDbContextFactory<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 (game, _) = TestDbHelper.SeedGame(db, host);
var result = await _sut.StartGameAsync(game.Id);
Assert.Equal(GameStatus.InProgress, result.Status);
Assert.NotNull(result.StartedAt);
var round = db.GameRounds.Single(r => r.GameId == game.Id);
Assert.Equal(1, round.RoundNumber);
Assert.Equal(RoundStatus.Waiting, round.Status);
}
[Fact]
public async Task StartGameAsync_Throws_WhenGameNotFound()
{
await Assert.ThrowsAsync<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);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
db.PlayerCards.Add(card);
await db.SaveChangesAsync();
var result = await _sut.SubmitSlipAsync(game.Id, hostPlayer.Id, card.Id);
Assert.True(result);
using var verify = _factory.CreateDbContext();
Assert.True(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed);
}
[Fact]
public async Task SubmitSlipAsync_Throws_WhenCardNotFound()
{
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SubmitSlipAsync(1, 1, 999));
}
[Fact]
public async Task SubmitSlipAsync_Throws_WhenCardBelongsToOtherPlayer()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
db.Players.Add(otherPlayer);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
db.PlayerCards.Add(card);
await db.SaveChangesAsync();
// otherPlayer versucht, die Karte des Hosts zu verwenden
await Assert.ThrowsAsync<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 };
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));
}
// ---------- ResolveChallengeAsync ----------
[Fact]
public async Task ResolveChallengeAsync_Approved_KeepsCardWithTargetPlayer()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
db.Players.Add(otherPlayer);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
db.PlayerCards.Add(card);
var challenge = new SlipChallenge
{
GameRoundId = round.Id,
ChallengingPlayerId = hostPlayer.Id,
TargetPlayerId = otherPlayer.Id,
TargetCardId = card.Id,
Status = ChallengeStatus.Pending
};
db.SlipChallenges.Add(challenge);
await db.SaveChangesAsync();
var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: true);
Assert.Equal(ChallengeStatus.Approved, result.Status);
Assert.NotNull(result.ResolvedAt);
// Karte bleibt beim Beschuldigten
using var verify = _factory.CreateDbContext();
Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
}
[Fact]
public async Task ResolveChallengeAsync_Rejected_TransfersCardToChallenger()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
db.Players.Add(otherPlayer);
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
db.GameRounds.Add(round);
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
db.PlayerCards.Add(card);
var challenge = new SlipChallenge
{
GameRoundId = round.Id,
ChallengingPlayerId = hostPlayer.Id,
TargetPlayerId = otherPlayer.Id,
TargetCardId = card.Id,
Status = ChallengeStatus.Pending
};
db.SlipChallenges.Add(challenge);
await db.SaveChangesAsync();
var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: false);
Assert.Equal(ChallengeStatus.Rejected, result.Status);
Assert.NotNull(result.ResolvedAt);
// Karte geht an den Beschuldiger
using var verify = _factory.CreateDbContext();
Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
}
[Fact]
public async Task ResolveChallengeAsync_Throws_WhenChallengeNotFound()
{
await Assert.ThrowsAsync<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 };
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.11" />
<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

@@ -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

@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using SlipItIn.Server.Data; using SlipItIn.Server.Data;
using SlipItIn.Server.Hubs; using SlipItIn.Server.Hubs;
using SlipItIn.Server.Services; using SlipItIn.Server.Services;
using System.Security.Claims;
using System.Text; using System.Text;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -50,6 +51,24 @@ builder.Services
context.Token = accessToken; context.Token = accessToken;
} }
return Task.CompletedTask; 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 +98,28 @@ var app = builder.Build();
// Aspire Default Endpoints (Health Checks) // Aspire Default Endpoints (Health Checks)
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
// Migrations anwenden // Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
db.Database.Migrate(); db.Database.Migrate();
await DbSeeder.SeedAsync(db);
} }
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); 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.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
<Solution> <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.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.Shared/SlipItIn.Shared.csproj" />

View File

@@ -8,34 +8,57 @@ public partial class App : Application
public App() public App()
{ {
InitializeComponent(); InitializeComponent();
_ = InitializeAsync();
} }
protected override Window CreateWindow(IActivationState? activationState) protected override Window CreateWindow(IActivationState? activationState)
{ {
var window = new Window(new AppShell()); var window = new Window(new AppShell());
window.Created += async (_, _) =>
{
await InitializeSafeAsync();
};
window.Resumed += async (_, _) => window.Resumed += async (_, _) =>
{ {
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>(); var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync(); await gameStateService.ResyncAsync();
}; };
return window; return window;
} }
private static async Task InitializeAsync() private static async Task InitializeSafeAsync()
{
try
{ {
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>(); var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
var signalR = ServiceHelper.GetRequiredService<ISignalRService>(); var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>(); var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
var apiService = ServiceHelper.GetRequiredService<IApiService>();
await authSession.InitializeAsync(); await authSession.InitializeAsync();
await gameStateService.InitializeAsync(); await gameStateService.InitializeAsync();
if (!string.IsNullOrWhiteSpace(authSession.AccessToken)) if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return;
var isSessionValid = await apiService.ValidateSessionAsync();
if (!isSessionValid)
{ {
await authSession.ClearSessionAsync();
await gameStateService.ClearLocalGameDataAsync();
await signalR.DisconnectAsync();
return;
}
var connected = await signalR.ConnectAsync(authSession.AccessToken); var connected = await signalR.ConnectAsync(authSession.AccessToken);
if (connected) if (connected)
await gameStateService.ResyncAsync(); await gameStateService.ResyncAsync();
} }
catch
{
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
}
} }
} }

View File

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

View File

@@ -7,5 +7,8 @@ public partial class AppShell : Shell
public AppShell() public AppShell()
{ {
InitializeComponent(); 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

@@ -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,23 @@ public class ApiService : IApiService
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default) public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken); => PostAuthAsync("api/auth/login", request, cancellationToken);
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
return false;
try
{
AttachAuthHeader();
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public Task LogoutAsync() => _authSession.ClearSessionAsync(); public Task LogoutAsync() => _authSession.ClearSessionAsync();
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken) 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 public class AppConfigurationService : IAppConfigurationService
{ {
private const string ApiBaseUrlPreferenceKey = "api_base_url";
private const string DefaultApiBaseUrl = "https://localhost:7274"; 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 ISignalRService _signalR;
private readonly ILocalStorageService _localStorage; private readonly ILocalStorageService _localStorage;
private readonly IMessenger _messenger; private readonly IMessenger _messenger;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private readonly List<QueuedGameAction> _queuedActions = []; private readonly List<QueuedGameAction> _queuedActions = [];
@@ -50,7 +51,21 @@ public class GameStateService : IGameStateService
}; };
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error)); _signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += (_, connected) => _messenger.Send(new ConnectionStateChangedMessage(connected)); _signalR.ConnectionStateChanged += async (_, connected) =>
{
_messenger.Send(new ConnectionStateChangedMessage(connected));
if (!connected)
return;
try
{
await ResyncAsync();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
}
};
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@@ -67,14 +82,32 @@ public class GameStateService : IGameStateService
if (CurrentPlayerHand is not null) if (CurrentPlayerHand is not null)
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand)); _messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
PublishSyncState(false, "Bereit", _queuedActions.Count);
} }
public async Task ResyncAsync() public async Task ResyncAsync()
{ {
await _syncLock.WaitAsync();
try
{
PublishSyncState(true, "Syncing...", _queuedActions.Count);
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected) if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId); await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsAsync(); 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) public async Task EnqueueActionAsync(string method, params object[] arguments)
@@ -88,21 +121,22 @@ public class GameStateService : IGameStateService
_queuedActions.Add(action); _queuedActions.Add(action);
await _localStorage.SaveQueuedActionsAsync(_queuedActions); await _localStorage.SaveQueuedActionsAsync(_queuedActions);
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
} }
public async Task FlushQueuedActionsAsync() public async Task FlushQueuedActionsAsync()
{ {
if (!_signalR.IsConnected || _queuedActions.Count == 0) await _syncLock.WaitAsync();
return; try
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{ {
await _signalR.SendQueuedActionAsync(action); PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
_queuedActions.Remove(action); await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
}
finally
{
_syncLock.Release();
} }
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
} }
public async Task ClearLocalGameDataAsync() public async Task ClearLocalGameDataAsync()
@@ -115,5 +149,36 @@ public class GameStateService : IGameStateService
await _localStorage.ClearGameStateAsync(); await _localStorage.ClearGameStateAsync();
await _localStorage.ClearPlayerHandAsync(); await _localStorage.ClearPlayerHandAsync();
await _localStorage.ClearQueuedActionsAsync(); await _localStorage.ClearQueuedActionsAsync();
PublishSyncState(false, "Bereit", 0);
}
private async Task FlushQueuedActionsCoreAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{
try
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break;
}
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
private void PublishSyncState(bool isSyncing, string statusText, int pendingActions)
{
_messenger.Send(new SyncStateChangedMessage(new SyncStateInfo(isSyncing, statusText, pendingActions)));
} }
} }

View File

@@ -6,5 +6,6 @@ public interface IApiService
{ {
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default); Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default); Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task LogoutAsync(); Task LogoutAsync();
} }

View File

@@ -38,6 +38,8 @@ public class SignalRService : ISignalRService
if (_hubConnection is null) if (_hubConnection is null)
return false; return false;
ConnectionStateChanged?.Invoke(this, false);
try try
{ {
await _hubConnection.StartAsync(cancellationToken); await _hubConnection.StartAsync(cancellationToken);
@@ -46,6 +48,7 @@ public class SignalRService : ISignalRService
} }
catch (Exception ex) catch (Exception ex)
{ {
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}"); ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false; return false;
} }
@@ -56,7 +59,9 @@ public class SignalRService : ISignalRService
if (_hubConnection is null) if (_hubConnection is null)
return; return;
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync(); await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false); ConnectionStateChanged?.Invoke(this, false);
} }
@@ -108,11 +113,17 @@ public class SignalRService : ISignalRService
_hubConnection = new HubConnectionBuilder() _hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options => .WithUrl(_configuration.HubUrl, options =>
{ {
options.AccessTokenProvider = () => Task.FromResult<string?>(token); options.AccessTokenProvider = () => Task.FromResult<string?>(_authSession.AccessToken ?? token);
}) })
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)]) .WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build(); .Build();
_hubConnection.Reconnecting += _ =>
{
ConnectionStateChanged?.Invoke(this, false);
return Task.CompletedTask;
};
_hubConnection.Closed += _ => _hubConnection.Closed += _ =>
{ {
ConnectionStateChanged?.Invoke(this, false); ConnectionStateChanged?.Invoke(this, false);

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks> <TargetFrameworks>net10.0-android</TargetFrameworks>
@@ -99,10 +99,10 @@
</ItemGroup> </ItemGroup>
<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="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <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>
<ItemGroup> <ItemGroup>
@@ -110,6 +110,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<MauiXaml Update="MainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml"> <MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</MauiXaml> </MauiXaml>
@@ -121,4 +124,12 @@
</MauiXaml> </MauiXaml>
</ItemGroup> </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> </Project>

View File

@@ -12,7 +12,9 @@ public partial class GameBoardViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>, IRecipient<GameStateChangedMessage>,
IRecipient<PlayerHandChangedMessage>, IRecipient<PlayerHandChangedMessage>,
IRecipient<ChallengeReceivedMessage>, IRecipient<ChallengeReceivedMessage>,
IRecipient<ErrorOccurredMessage> IRecipient<ErrorOccurredMessage>,
IRecipient<SyncStateChangedMessage>,
IRecipient<ConnectionStateChangedMessage>
{ {
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
@@ -33,6 +35,18 @@ public partial class GameBoardViewModel : BaseViewModel,
[ObservableProperty] [ObservableProperty]
private SlipChallengeDto? pendingChallenge; 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<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = []; public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
@@ -42,7 +56,7 @@ public partial class GameBoardViewModel : BaseViewModel,
_gameStateService = gameStateService; _gameStateService = gameStateService;
_authSession = authSession; _authSession = authSession;
WeakReferenceMessenger.Default.RegisterAll(this); IsConnected = _signalR.IsConnected;
IsActive = true; IsActive = true;
} }
@@ -165,6 +179,18 @@ public partial class GameBoardViewModel : BaseViewModel,
StatusMessage = message.Value; StatusMessage = message.Value;
} }
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
}
private int GetCurrentPlayerId() private int GetCurrentPlayerId()
{ {
var username = _authSession.CurrentUser?.Username; var username = _authSession.CurrentUser?.Username;

View File

@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
IRecipient<LobbyCreatedMessage>, IRecipient<LobbyCreatedMessage>,
IRecipient<GameStartedMessage>, IRecipient<GameStartedMessage>,
IRecipient<ErrorOccurredMessage>, IRecipient<ErrorOccurredMessage>,
IRecipient<ConnectionStateChangedMessage> IRecipient<ConnectionStateChangedMessage>,
IRecipient<SyncStateChangedMessage>
{ {
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
[ObservableProperty] [ObservableProperty]
private bool isConnected; private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerInfoDto> Players { get; } = []; public ObservableCollection<PlayerInfoDto> Players { get; } = [];
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService) public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
@@ -42,7 +52,6 @@ public partial class LobbyViewModel : BaseViewModel,
_apiService = apiService; _apiService = apiService;
IsConnected = _signalR.IsConnected; IsConnected = _signalR.IsConnected;
//WeakReferenceMessenger.Default.RegisterAll(this);
IsActive = true; IsActive = true;
} }
@@ -127,7 +136,7 @@ public partial class LobbyViewModel : BaseViewModel,
public async void Receive(GameStartedMessage message) public async void Receive(GameStartedMessage message)
{ {
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync($"//{nameof(Views.GameBoardPage)}")); await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage)));
} }
public void Receive(ErrorOccurredMessage message) public void Receive(ErrorOccurredMessage message)
@@ -140,6 +149,13 @@ public partial class LobbyViewModel : BaseViewModel,
IsConnected = message.Value; IsConnected = message.Value;
} }
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
private int GetCurrentPlayerId() private int GetCurrentPlayerId()
{ {
var username = _authSession.CurrentUser?.Username; var username = _authSession.CurrentUser?.Username;

View File

@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
[ObservableProperty] [ObservableProperty]
public partial string Email { get; set; } private string email = string.Empty;
[ObservableProperty] [ObservableProperty]
public partial string Password { get; set; } private string password = string.Empty;
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService) public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
{ {
@@ -28,7 +28,7 @@ public partial class LoginViewModel : BaseViewModel
} }
[RelayCommand] [RelayCommand]
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}"); private Task GoToRegisterAsync() => Shell.Current.GoToAsync(nameof(RegisterPage));
[RelayCommand] [RelayCommand]
private async Task LoginAsync() private async Task LoginAsync()
@@ -42,20 +42,37 @@ public partial class LoginViewModel : BaseViewModel
}); });
await _authSession.SetSessionAsync(response); 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 _gameStateService.InitializeAsync();
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}"); await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Anmeldung läuft..."); }, "Anmeldung läuft...");
} }
[RelayCommand] [RelayCommand]
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
bool isAuthenticated = _authSession.IsAuthenticated; if (!_authSession.IsAuthenticated)
return;
if ( isAuthenticated) var isSessionValid = await _apiService.ValidateSessionAsync();
if (!isSessionValid)
{ {
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; private readonly ISignalRService _signalR;
[ObservableProperty] [ObservableProperty]
public partial string Username { get; set; } = string.Empty; private string username = string.Empty;
[ObservableProperty] [ObservableProperty]
public partial string Email { get; set; } = string.Empty; private string email = string.Empty;
[ObservableProperty] [ObservableProperty]
public partial string Password { get; set; } = string.Empty; private string password = string.Empty;
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR) public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
{ {
@@ -45,7 +45,7 @@ public partial class RegisterViewModel : BaseViewModel
await _authSession.SetSessionAsync(response); await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token); await _signalR.ConnectAsync(response.Token);
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}"); await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Registrierung läuft..."); }, "Registrierung läuft...");
} }
} }

View File

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

View File

@@ -7,6 +7,8 @@
<VerticalStackLayout Padding="24" Spacing="12"> <VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" /> <Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" /> <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}" /> <Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />