bollwerk/run-integration-tests.ps1
Jens Reinemann dad15b9e94 security: WebSocket Auth-Token aus Query-Parameter in Authorization-Header verschieben
- Client: Token als 'Authorization: Bearer' Header statt ?token= Query-Parameter senden
- Server: Token aus Authorization-Header statt Query-Parameter lesen
- Tests: Alle 8 WebSocket-Tests auf Header-Auth umgestellt
- Integration-Tests: WebSocket-Verbindung mit Header aktualisiert

Closes #97
2026-05-18 08:23:10 +02:00

884 lines
41 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<#
.SYNOPSIS
Bollwerk Integration Test Suite
.DESCRIPTION
Testet Auth, Inventory Sync, Messaging (offline delivery), JWT Refresh
und parallele WebSocket-Sessions gegen einen laufenden Server.
.PARAMETER BaseUrl
Server-URL. Standard: https://bollwerk.online
.PARAMETER AlicePassword
Passwort fuer Alice. Standard: alice
.PARAMETER BobPassword
Passwort fuer Bob. Standard: bob
.EXAMPLE
.\run-integration-tests.ps1
.\run-integration-tests.ps1 -BaseUrl "http://localhost:8080"
#>
param(
[string]$BaseUrl = "https://bollwerk.online",
[string]$AliceUser = "alice",
[string]$AlicePassword = "alice",
[string]$BobUser = "bob",
[string]$BobPassword = "bob"
)
$ErrorActionPreference = "Stop"
$WsUrl = $BaseUrl -replace "^http", "ws"
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
$script:PassCount = 0
$script:FailCount = 0
function Pass([string]$msg) {
Write-Host "[PASS] $msg" -ForegroundColor Green
$script:PassCount++
}
function Fail([string]$msg) {
Write-Host "[FAIL] $msg" -ForegroundColor Red
$script:FailCount++
}
function Info([string]$msg) {
Write-Host " $msg" -ForegroundColor DarkGray
}
function Section([string]$title) {
Write-Host ""
Write-Host "--- $title ---" -ForegroundColor Cyan
}
function Invoke-Api {
param(
[string]$Method,
[string]$Path,
[object]$Body = $null,
[string]$Token = $null
)
$headers = @{ "Content-Type" = "application/json" }
if ($Token) { $headers["Authorization"] = "Bearer $Token" }
$bodyJson = if ($Body) { $Body | ConvertTo-Json -Depth 10 -Compress } else { $null }
# PS5.1 sendet String-Body mit Windows-Systemkodierung statt UTF-8.
# Alle Non-ASCII-Zeichen als \uXXXX escapen → Body ist ASCII-sicher.
if ($bodyJson) {
$sb = [System.Text.StringBuilder]::new($bodyJson.Length * 2)
foreach ($ch in $bodyJson.ToCharArray()) {
if ([int]$ch -gt 127) { [void]$sb.Append('\u{0:x4}' -f [int]$ch) }
else { [void]$sb.Append($ch) }
}
$bodyJson = $sb.ToString()
}
try {
$response = Invoke-RestMethod -Method $Method `
-Uri "$BaseUrl$Path" `
-Body $bodyJson `
-Headers $headers `
-ErrorAction Stop
return $response
}
catch {
$status = $_.Exception.Response.StatusCode.value__
throw "HTTP $status bei $Method $Path"
}
}
# WebSocket-Verbindung oeffnen (gibt ClientWebSocket zurueck)
function Open-WebSocket([string]$token) {
$ws = New-Object System.Net.WebSockets.ClientWebSocket
$ws.Options.SetRequestHeader("Authorization", "Bearer $token")
$uri = [Uri]"$WsUrl/ws/sync"
$ws.ConnectAsync($uri, [System.Threading.CancellationToken]::None).Wait()
return $ws
}
# Alle anstehenden Frames eines WebSocket empfangen (waitSeconds: max. Wartezeit)
# WICHTIG: CancellationToken darf NICHT in ReceiveAsync verwendet werden
# das abortet die gesamte .NET ClientWebSocket-Verbindung.
# Stattdessen: Task.Wait(timeout) als Deadline-Mechanismus.
# Gibt immer ein Array zurueck (,$ verhindert PS-Unwrapping bei 1 Element).
function Receive-WsMessages([System.Net.WebSockets.ClientWebSocket]$ws, [int]$waitSeconds = 4) {
$messages = @()
$buffer = New-Object byte[] 8192
$deadline = [DateTime]::UtcNow.AddSeconds($waitSeconds)
while ($ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
$remaining = [int]($deadline - [DateTime]::UtcNow).TotalMilliseconds
if ($remaining -le 0) { break }
$seg = [ArraySegment[byte]]::new($buffer)
try {
$task = $ws.ReceiveAsync($seg, [System.Threading.CancellationToken]::None)
$completed = $task.Wait($remaining)
if (-not $completed) { break } # Deadline abgelaufen, kein Frame
$result = $task.Result
if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { break }
if ($result.Count -gt 0) {
$text = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)
try { $messages += ($text | ConvertFrom-Json) } catch { }
}
}
catch {
break
}
}
return , $messages # Komma verhindert PS-Unwrapping bei 1 Element
}
function Close-WebSocket([System.Net.WebSockets.ClientWebSocket]$ws) {
try { $ws.Abort() } catch { }
$ws.Dispose()
}
# ---------------------------------------------------------------------------
# Szenario 1: Auth-Flow
# ---------------------------------------------------------------------------
Section "Szenario 1: Auth-Flow"
$aliceTokens = $null
$bobTokens = $null
try {
$aliceTokens = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $AliceUser; password = $AlicePassword }
Pass "Auth: Alice login (userId=$($aliceTokens.userId))"
}
catch { Fail "Auth: Alice login - $_" }
try {
$bobTokens = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $BobUser; password = $BobPassword }
Pass "Auth: Bob login (userId=$($bobTokens.userId))"
}
catch { Fail "Auth: Bob login - $_" }
# Geschuetzten Endpoint mit Token aufrufen
if ($aliceTokens) {
try {
$inv = Invoke-Api -Method GET -Path "/api/inventory" -Token $aliceTokens.accessToken
Pass "Auth: Alice-Token fuer geschuetzten Endpoint gueltig"
}
catch { Fail "Auth: Alice-Token ungueltig - $_" }
}
# ---------------------------------------------------------------------------
# Szenario 2: Inventory Sync (Alice laedt hoch und liest zurueck)
# ---------------------------------------------------------------------------
Section "Szenario 2: Inventory Sync"
$itemId = [System.Guid]::NewGuid().ToString()
$testInventory = @{
version = 1
categories = @(@{ id = 1; name = "Lebensmittel" })
locations = @(@{ id = 1; name = "Keller" })
items = @(@{
id = $itemId
name = "Testkonserve"
categoryId = 1
quantity = 12.0
unit = "Stueck"
unitPrice = 1.5
kcalPerUnit = $null
expiryDate = "2027-12-31"
locationId = 1
notes = "Integrationstest"
lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
})
settings = @()
}
if ($aliceTokens) {
try {
$uploaded = Invoke-Api -Method PUT -Path "/api/inventory" -Body $testInventory -Token $aliceTokens.accessToken
Pass "Inventory Sync: Alice laedt Inventar hoch ($($uploaded.items.Count) Item(s))"
}
catch { Fail "Inventory Sync: Upload fehlgeschlagen - $_" }
try {
$fetched = Invoke-Api -Method GET -Path "/api/inventory" -Token $aliceTokens.accessToken
$item = $fetched.items | Where-Object { $_.id -eq $itemId }
if ($item -and $item.name -eq "Testkonserve" -and $item.quantity -eq 12.0) {
Pass "Inventory Sync: Abgerufenes Inventar konsistent (name=$($item.name), qty=$($item.quantity))"
}
else {
Fail "Inventory Sync: Abgerufenes Item stimmt nicht ueberein"
}
}
catch { Fail "Inventory Sync: GET fehlgeschlagen - $_" }
# PATCH eines einzelnen Items
try {
$patch = @{
id = $itemId; name = "Testkonserve"; categoryId = 1
quantity = 24.0; unit = "Stueck"; unitPrice = 1.5
kcalPerUnit = $null; expiryDate = "2027-12-31"; locationId = 1
notes = "Nach PATCH"; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
Invoke-Api -Method PATCH -Path "/api/inventory/items/$itemId" -Body $patch -Token $aliceTokens.accessToken | Out-Null
$fetched2 = Invoke-Api -Method GET -Path "/api/inventory" -Token $aliceTokens.accessToken
$patched = $fetched2.items | Where-Object { $_.id -eq $itemId }
if ($patched -and $patched.quantity -eq 24.0) {
Pass "Inventory Sync: PATCH item erfolgreich (qty=$($patched.quantity))"
}
else {
Fail "Inventory Sync: PATCH-Ergebnis nicht korrekt"
}
}
catch { Fail "Inventory Sync: PATCH fehlgeschlagen - $_" }
}
# ---------------------------------------------------------------------------
# Szenario 3: Messaging + Offline Delivery
# ---------------------------------------------------------------------------
Section "Szenario 3: Messaging + Offline Delivery"
if ($aliceTokens -and $bobTokens) {
$bobId = $bobTokens.userId
$aliceId = $aliceTokens.userId
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
# Alice sendet 3 Nachrichten an Bob (Bob hat keine aktive WS-Verbindung)
$sentIds = @()
$allSent = $true
for ($i = 1; $i -le 3; $i++) {
try {
$msg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{
receiverId = $bobId
body = "Hallo Bob, Nachricht $i von Alice"
sentAt = $now + $i
}
$sentIds += $msg.id
}
catch {
Fail "Messaging: Alice sendet Nachricht $i - $_"
$allSent = $false
}
}
if ($allSent) { Pass "Messaging: Alice sendet 3 Nachrichten an Bob (offline)" }
# Bob verbindet sich per WebSocket -> muss die 3 unzugestellten Nachrichten empfangen
try {
$bobWs = Open-WebSocket -token $bobTokens.accessToken
Info "Bob WebSocket verbunden (State=$($bobWs.State))"
Start-Sleep -Milliseconds 500
$received = Receive-WsMessages -ws $bobWs
Close-WebSocket -ws $bobWs
$newMsgs = $received | Where-Object { $_.type -eq "new_message" }
if ($newMsgs.Count -ge 3) {
Pass "Messaging: Bob empfaengt $($newMsgs.Count) Nachrichten nach WS-Connect (Offline-Delivery)"
}
else {
Fail "Messaging: Bob empfaengt nur $($newMsgs.Count)/3 Nachrichten (erwartet 3)"
}
}
catch { Fail "Messaging: Bob WebSocket - $_" }
# Bob antwortet 2 Nachrichten (Alice ist jetzt offline)
$replySent = $true
for ($i = 1; $i -le 2; $i++) {
try {
Invoke-Api -Method POST -Path "/api/messages" -Token $bobTokens.accessToken -Body @{
receiverId = $aliceId
body = "Antwort $i von Bob"
sentAt = $now + 100 + $i
} | Out-Null
}
catch {
Fail "Messaging: Bob sendet Antwort $i - $_"
$replySent = $false
}
}
if ($replySent) { Pass "Messaging: Bob sendet 2 Antworten (Alice offline)" }
# Alice verbindet sich -> empfaengt Bobs Antworten als Offline-Delivery
try {
$aliceWs = Open-WebSocket -token $aliceTokens.accessToken
Start-Sleep -Milliseconds 500
$aliceReceived = Receive-WsMessages -ws $aliceWs
Close-WebSocket -ws $aliceWs
$aliceMsgs = $aliceReceived | Where-Object { $_.type -eq "new_message" }
if ($aliceMsgs.Count -ge 2) {
Pass "Messaging: Alice empfaengt $($aliceMsgs.Count) Offline-Nachrichten von Bob nach Reconnect"
}
else {
Fail "Messaging: Alice empfaengt nur $($aliceMsgs.Count)/2 Nachrichten (erwartet 2)"
}
}
catch { Fail "Messaging: Alice WebSocket Reconnect - $_" }
# Konversations-Verlauf per HTTP
try {
$conv = Invoke-Api -Method GET -Path "/api/messages/$bobId" -Token $aliceTokens.accessToken
if ($conv.Count -ge 5) {
Pass "Messaging: Konversationsverlauf enthaelt $($conv.Count) Nachrichten"
}
else {
Fail "Messaging: Konversation enthaelt nur $($conv.Count) Nachrichten (erwartet >=5)"
}
}
catch { Fail "Messaging: Konversationsverlauf abruf - $_" }
}
# ---------------------------------------------------------------------------
# Szenario 4: JWT Refresh
# ---------------------------------------------------------------------------
Section "Szenario 4: JWT Refresh"
if ($aliceTokens) {
try {
$refreshed = Invoke-Api -Method POST -Path "/api/auth/refresh" -Body @{ refreshToken = $aliceTokens.refreshToken }
if ($refreshed.accessToken -and $refreshed.accessToken -ne $aliceTokens.accessToken) {
Pass "JWT Refresh: Neuer Access-Token erhalten"
# Alten Token sollte unbrauchbar sein nicht zwingend pruefbar; neuer Token muss funktionieren
$inv = Invoke-Api -Method GET -Path "/api/inventory" -Token $refreshed.accessToken
Pass "JWT Refresh: Neuer Access-Token fuer geschuetzten Endpoint gueltig"
}
else {
Fail "JWT Refresh: Kein neuer Token erhalten"
}
}
catch { Fail "JWT Refresh: $_" }
}
# ---------------------------------------------------------------------------
# Szenario 5: Parallele Sessions
# ---------------------------------------------------------------------------
Section "Szenario 5: Parallele Sessions"
if ($aliceTokens -and $bobTokens) {
$ws1 = $null
$ws2 = $null
try {
# Alice oeffnet 2 gleichzeitige WebSocket-Verbindungen
$ws1 = Open-WebSocket -token $aliceTokens.accessToken
$ws2 = Open-WebSocket -token $aliceTokens.accessToken
Info "Alice: 2 WebSocket-Verbindungen offen (State1=$($ws1.State), State2=$($ws2.State))"
if ($ws1.State -eq "Open" -and $ws2.State -eq "Open") {
Pass "Parallele Sessions: Beide Verbindungen geoeffnet"
}
else {
Fail "Parallele Sessions: Nicht beide Verbindungen offen"
}
# Warte bis Sessions serverseitig registriert sind
Start-Sleep -Milliseconds 1500
# Bob schickt Nachricht an Alice (sie ist online mit 2 Sessions)
Invoke-Api -Method POST -Path "/api/messages" -Token $bobTokens.accessToken -Body @{
receiverId = $aliceTokens.userId
body = "Broadcast-Test von Bob"
sentAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
} | Out-Null
# Kurz warten, dann beide Verbindungen schliessen
Start-Sleep -Milliseconds 500
Close-WebSocket -ws $ws1; $ws1 = $null
Close-WebSocket -ws $ws2; $ws2 = $null
# Indirekter Test: Wenn der Server Alice als "online" erkannt hat,
# wurde die Nachricht als delivered markiert (NICHT in undelivered-Queue).
# Eine neue WS-Verbindung von Alice darf KEINE undelivered messages empfangen.
Start-Sleep -Milliseconds 500
$ws3 = Open-WebSocket -token $aliceTokens.accessToken
Start-Sleep -Milliseconds 800
$pending = Receive-WsMessages -ws $ws3 -waitSeconds 2
Close-WebSocket -ws $ws3; $ws3 = $null
$undeliveredCount = ($pending | Where-Object { $_.type -eq "new_message" }).Count
if ($undeliveredCount -eq 0) {
Pass "Parallele Sessions: Server hat Alice korrekt als online erkannt - Nachricht als delivered markiert (nicht in Offline-Queue)"
}
else {
Fail "Parallele Sessions: Server hat $undeliveredCount Nachricht(en) als undelivered gespeichert, obwohl Alice online war"
}
}
catch { Fail "Parallele Sessions: $_" }
finally {
if ($ws1) { Close-WebSocket -ws $ws1 }
if ($ws2) { Close-WebSocket -ws $ws2 }
}
}
# ---------------------------------------------------------------------------
# Szenario 6: Bob-Generaltest (10 Items, PATCH, WebSocket-Push)
# ---------------------------------------------------------------------------
Section "Szenario 6: Bob-Generaltest"
# ---------------------------------------------------------------------------
# Szenario 6a: UTF-8-Unterstuetzung (Umlaute und Emojis im Chat)
# ---------------------------------------------------------------------------
Section "Szenario 6a: UTF-8 & Emoji im Chat"
if ($aliceTokens -and $bobTokens) {
$bobId = $bobTokens.userId
$aliceId = $aliceTokens.userId
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
# ---- Testfall 1: Nachricht mit Umlauten senden und empfangen ----
try {
$umlautBody = "Schöne Grüße aus München äöüÄÖÜß"
$umlautMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{
receiverId = $bobId
body = $umlautBody
sentAt = $now + 1000
}
if ($umlautMsg.body -eq $umlautBody) {
Pass "UTF-8: Umlaut-Nachricht korrekt gespeichert und zurueckgegeben (äöüÄÖÜß)"
}
else {
Fail "UTF-8: Umlaut-Nachricht veraendert: erwartet='$umlautBody', erhalten='$($umlautMsg.body)'"
}
}
catch { Fail "UTF-8: Umlaut-Nachricht senden - $_" }
# ---- Testfall 2: Nachricht mit Emojis senden und empfangen ----
try {
$emojiBody = "Guten Morgen! 😀🎉❤️👍"
$emojiMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{
receiverId = $bobId
body = $emojiBody
sentAt = $now + 2000
}
if ($emojiMsg.body -eq $emojiBody) {
Pass "UTF-8: Emoji-Nachricht korrekt gespeichert und zurueckgegeben (😀🎉❤️👍)"
}
else {
Fail "UTF-8: Emoji-Nachricht veraendert: erwartet='$emojiBody', erhalten='$($emojiMsg.body)'"
}
}
catch { Fail "UTF-8: Emoji-Nachricht senden - $_" }
# ---- Testfall 3: Gemischte Nachricht (Umlaut + Emoji + ASCII) ----
try {
$mixedBody = "Schöner Tag 😀! Grüße & Küsse 💋"
$mixedMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{
receiverId = $bobId
body = $mixedBody
sentAt = $now + 3000
}
if ($mixedMsg.body -eq $mixedBody) {
Pass "UTF-8: Gemischte Nachricht (Umlaut+Emoji+ASCII) korrekt"
}
else {
Fail "UTF-8: Gemischte Nachricht veraendert: erwartet='$mixedBody', erhalten='$($mixedMsg.body)'"
}
}
catch { Fail "UTF-8: Gemischte Nachricht senden - $_" }
# ---- Testfall 4: Konversation mit Umlauten/Emojis abrufen ----
try {
$conv = Invoke-Api -Method GET -Path "/api/messages/$bobId" -Token $aliceTokens.accessToken
$umlautFound = $conv | Where-Object { $_.body -like "*äöüÄÖÜß*" }
$emojiFound = $conv | Where-Object { $_.body -like "*😀*" }
if ($umlautFound -and $emojiFound) {
Pass "UTF-8: Konversationsverlauf enthaelt Umlaute und Emojis korrekt"
}
else {
Fail "UTF-8: Konversationsverlauf: umlaut=$([bool]$umlautFound), emoji=$([bool]$emojiFound)"
}
}
catch { Fail "UTF-8: Konversation abrufen - $_" }
# ---- Testfall 5: WebSocket-Delivery mit UTF-8 ----
try {
$bobWsUtf8 = Open-WebSocket -token $bobTokens.accessToken
Start-Sleep -Milliseconds 500
$wsBody = "Hallo Böb! 🚀 Schöne Grüße"
Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{
receiverId = $bobId
body = $wsBody
sentAt = $now + 4000
} | Out-Null
Start-Sleep -Milliseconds 500
$wsReceived = Receive-WsMessages -ws $bobWsUtf8 -waitSeconds 4
Close-WebSocket -ws $bobWsUtf8
$wsMsg = $wsReceived | Where-Object { $_.type -eq "new_message" -and $_.body -eq $wsBody }
if ($wsMsg) {
Pass "UTF-8: WebSocket-Delivery mit Umlauten und Emojis korrekt"
}
else {
$bodies = ($wsReceived | Where-Object { $_.type -eq "new_message" } | Select-Object -ExpandProperty body) -join "; "
Fail "UTF-8: WebSocket-Body veraendert. Bodies: $bodies"
}
}
catch { Fail "UTF-8: WebSocket UTF-8 Delivery - $_" }
}
if ($bobTokens) {
# Artikel-IDs fuer das Haupt-Szenario
$dosenBrotId = [System.Guid]::NewGuid().ToString()
$wasserId = [System.Guid]::NewGuid().ToString()
$thunfischId = [System.Guid]::NewGuid().ToString()
$nudelnId = [System.Guid]::NewGuid().ToString()
$toilettenPapierId = [System.Guid]::NewGuid().ToString()
$seifenId = [System.Guid]::NewGuid().ToString()
$muesliId = [System.Guid]::NewGuid().ToString()
$apfelsaftId = [System.Guid]::NewGuid().ToString()
$kerzenId = [System.Guid]::NewGuid().ToString()
$pflasterId = [System.Guid]::NewGuid().ToString()
$bob10Items = @{
version = 1
categories = @(
@{ id = 10; name = "Lebensmittel" }
@{ id = 11; name = "Getraenke" }
@{ id = 12; name = "Hygiene" }
)
locations = @(
@{ id = 10; name = "Keller" }
@{ id = 11; name = "Badezimmer" }
)
items = @(
@{ id = $dosenBrotId; name = "Dosenbrot"; categoryId = 10; quantity = 5.0; unit = "Stueck"; unitPrice = 2.0; kcalPerUnit = $null; expiryDate = "2028-12-31"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $wasserId; name = "Mineralwasser"; categoryId = 11; quantity = 24.0; unit = "Stueck"; unitPrice = 0.5; kcalPerUnit = $null; expiryDate = "2028-12-31"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $thunfischId; name = "Thunfisch"; categoryId = 10; quantity = 8.0; unit = "Dose"; unitPrice = 1.2; kcalPerUnit = $null; expiryDate = "2028-06-30"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $nudelnId; name = "Nudeln"; categoryId = 10; quantity = 3.0; unit = "Packung"; unitPrice = 0.9; kcalPerUnit = $null; expiryDate = "2029-01-01"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $toilettenPapierId; name = "Toilettenpapier"; categoryId = 12; quantity = 12.0; unit = "Rollen"; unitPrice = 0.3; kcalPerUnit = $null; expiryDate = $null; locationId = 11; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $seifenId; name = "Seife"; categoryId = 12; quantity = 4.0; unit = "Stueck"; unitPrice = 1.5; kcalPerUnit = $null; expiryDate = $null; locationId = 11; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $muesliId; name = "Muesliegel"; categoryId = 10; quantity = 20.0; unit = "Stueck"; unitPrice = 0.8; kcalPerUnit = $null; expiryDate = "2028-03-31"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $apfelsaftId; name = "Apfelsaft"; categoryId = 11; quantity = 6.0; unit = "Liter"; unitPrice = 1.0; kcalPerUnit = $null; expiryDate = "2028-09-30"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $kerzenId; name = "Kerzen"; categoryId = 10; quantity = 10.0; unit = "Stueck"; unitPrice = 1.5; kcalPerUnit = $null; expiryDate = $null; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
@{ id = $pflasterId; name = "Erste-Hilfe-Pflaster"; categoryId = 12; quantity = 2.0; unit = "Packung"; unitPrice = 3.5; kcalPerUnit = $null; expiryDate = "2029-06-30"; locationId = 11; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
)
settings = @()
}
# ---- Schritt 2: Bob pusht 10 Artikel ----
try {
$uploaded = Invoke-Api -Method PUT -Path "/api/inventory" -Body $bob10Items -Token $bobTokens.accessToken
Pass "Bob-Generaltest: 10 Artikel hochgeladen ($($uploaded.items.Count) Items)"
}
catch { Fail "Bob-Generaltest: PUT 10 Items fehlgeschlagen - $_" }
try {
$fetched = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
$itemCount = $fetched.items.Count
$catCount = $fetched.categories.Count
$locCount = $fetched.locations.Count
if ($itemCount -eq 10 -and $catCount -eq 3 -and $locCount -eq 2) {
Pass "Bob-Generaltest: GET liefert $itemCount Items, $catCount Kategorien, $locCount Lagerorte"
}
else {
Fail "Bob-Generaltest: Erwartet 10/3/2, erhalten $itemCount/$catCount/$locCount"
}
}
catch { Fail "Bob-Generaltest: GET nach PUT fehlgeschlagen - $_" }
# ---- Schritt 3: PATCH Dosenbrot qty 5 → 10 ----
try {
$patchBody = @{
id = $dosenBrotId; name = "Dosenbrot"; categoryId = 10
quantity = 10.0; unit = "Stueck"; unitPrice = 2.0
kcalPerUnit = $null; expiryDate = "2028-12-31"; locationId = 10
notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
Invoke-Api -Method PATCH -Path "/api/inventory/items/$dosenBrotId" -Body $patchBody -Token $bobTokens.accessToken | Out-Null
$f = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
$dosenbrot = $f.items | Where-Object { $_.id -eq $dosenBrotId }
if ($dosenbrot -and $dosenbrot.quantity -eq 10.0) {
Pass "Bob-Generaltest: PATCH Dosenbrot qty=10 erfolgreich"
}
else {
Fail "Bob-Generaltest: PATCH Dosenbrot qty=$($dosenbrot.quantity), erwartet 10"
}
}
catch { Fail "Bob-Generaltest: PATCH fehlgeschlagen - $_" }
# ---- Schritt 4: PUT mit 11 Items (Salzcracker neu) ----
$salzcrackerId = [System.Guid]::NewGuid().ToString()
$bob11Items = @{
version = 1
categories = $bob10Items.categories
locations = $bob10Items.locations
items = $bob10Items.items + @(
@{ id = $salzcrackerId; name = "Salzcracker"; categoryId = 10; quantity = 5.0; unit = "Packung"; unitPrice = 1.2; kcalPerUnit = $null; expiryDate = "2028-06-30"; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
)
settings = @()
}
try {
Invoke-Api -Method PUT -Path "/api/inventory" -Body $bob11Items -Token $bobTokens.accessToken | Out-Null
$f = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
if ($f.items.Count -eq 11) {
Pass "Bob-Generaltest: PUT mit 11 Items - GET liefert 11 Items"
}
else {
Fail "Bob-Generaltest: Erwartet 11 Items, erhalten $($f.items.Count)"
}
}
catch { Fail "Bob-Generaltest: PUT 11 Items fehlgeschlagen - $_" }
# ---- Schritt 5: Server-seitige Aenderung loest WebSocket-Push aus ----
$bobWsMain = $null
try {
$bobWsMain = Open-WebSocket -token $bobTokens.accessToken
Start-Sleep -Milliseconds 800
# Zweiter Bob-Client patcht Dosenbrot → qty 15
$bobTokens2 = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $BobUser; password = $BobPassword }
$patchBody2 = @{
id = $dosenBrotId; name = "Dosenbrot"; categoryId = 10
quantity = 15.0; unit = "Stueck"; unitPrice = 2.0
kcalPerUnit = $null; expiryDate = "2028-12-31"; locationId = 10
notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
Invoke-Api -Method PATCH -Path "/api/inventory/items/$dosenBrotId" -Body $patchBody2 -Token $bobTokens2.accessToken | Out-Null
$events = Receive-WsMessages -ws $bobWsMain -waitSeconds 5
Info "WS-Events Schritt 5: $($events.Count) Event(s) - $($events | ConvertTo-Json -Compress)"
$invUpdated = @($events | Where-Object { $_.type -eq "inventoryUpdated" -and $_.itemId -eq $dosenBrotId })
if ($invUpdated.Count -gt 0) {
Pass "Bob-Generaltest: WS empfaengt inventoryUpdated-Event mit korrekter itemId"
}
else {
Fail "Bob-Generaltest: WS empfaengt kein inventoryUpdated-Event (events: $($events.Count), raw: $($events | ConvertTo-Json -Compress))"
}
}
catch { Fail "Bob-Generaltest: WebSocket-Push fehlgeschlagen - $_" }
finally {
if ($bobWsMain) { Close-WebSocket -ws $bobWsMain; $bobWsMain = $null }
}
# ---- Schritt 6: Full-Sync-Push bei PUT ----
$bobWsFull = $null
try {
$bobWsFull = Open-WebSocket -token $bobTokens.accessToken
Start-Sleep -Milliseconds 800
$bobTokens3 = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $BobUser; password = $BobPassword }
Invoke-Api -Method PUT -Path "/api/inventory" -Body $bob11Items -Token $bobTokens3.accessToken | Out-Null
$events2 = Receive-WsMessages -ws $bobWsFull -waitSeconds 5
Info "WS-Events Schritt 6: $($events2.Count) Event(s) - $($events2 | ConvertTo-Json -Compress)"
$fullSync = @($events2 | Where-Object { $_.type -eq "fullSyncRequired" })
if ($fullSync.Count -gt 0) {
Pass "Bob-Generaltest: WS empfaengt fullSyncRequired-Event nach PUT"
}
else {
Fail "Bob-Generaltest: WS empfaengt kein fullSyncRequired-Event (events: $($events2.Count), raw: $($events2 | ConvertTo-Json -Compress))"
}
}
catch { Fail "Bob-Generaltest: Full-Sync-Push fehlgeschlagen - $_" }
finally {
if ($bobWsFull) { Close-WebSocket -ws $bobWsFull; $bobWsFull = $null }
}
# ---- T1: PATCH auf unbekannte Item-ID → 404 ----
try {
$unknownId = [System.Guid]::NewGuid().ToString()
$patchT1 = @{
id = $unknownId; name = "Unbekannt"; categoryId = 10
quantity = 1.0; unit = "Stueck"; unitPrice = 0.0
kcalPerUnit = $null; expiryDate = $null; locationId = 10
notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
try {
Invoke-Api -Method PATCH -Path "/api/inventory/items/$unknownId" -Body $patchT1 -Token $bobTokens.accessToken | Out-Null
Fail "T1 bob_patchNonExistentItem: 404 erwartet, kein Fehler erhalten"
}
catch {
if ($_ -match "404") { Pass "T1 bob_patchNonExistentItem: PATCH unbekannte ID → 404" }
else { Fail "T1 bob_patchNonExistentItem: Unerwarteter Fehler: $_" }
}
}
catch { Fail "T1 bob_patchNonExistentItem: $_" }
# ---- T5: Bob sieht nicht Alices Inventar ----
if ($aliceTokens) {
try {
$aliceFetch = Invoke-Api -Method GET -Path "/api/inventory" -Token $aliceTokens.accessToken
$bobFetch = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
$aliceIds = @($aliceFetch.items | Select-Object -ExpandProperty id)
$bobIds = @($bobFetch.items | Select-Object -ExpandProperty id)
$overlap = $aliceIds | Where-Object { $bobIds -contains $_ }
if ($overlap.Count -eq 0) {
Pass "T5 bob_cannotSeeAlice_inventory: Bob und Alice haben separate Inventare"
}
else {
Fail "T5 bob_cannotSeeAlice_inventory: $($overlap.Count) Item-IDs ueberschneiden sich"
}
}
catch { Fail "T5 bob_cannotSeeAlice_inventory: $_" }
}
# ---- T6: Ungueltiger Token → 401 ----
try {
$invalidToken = "invalid.jwt.token"
try {
Invoke-Api -Method GET -Path "/api/inventory" -Token $invalidToken | Out-Null
Fail "T6 bob_invalidToken: GET sollte 401 liefern"
}
catch {
if ($_ -match "401") { Pass "T6 bob_invalidToken: GET /api/inventory → 401" }
else { Fail "T6 bob_invalidToken: Unerwarteter Fehler bei GET: $_" }
}
$t6PatchId = [System.Guid]::NewGuid().ToString()
$t6Body = @{ id = $t6PatchId; name = "X"; categoryId = 10; quantity = 1.0; unit = "x"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 10; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
try {
Invoke-Api -Method PATCH -Path "/api/inventory/items/$t6PatchId" -Body $t6Body -Token $invalidToken | Out-Null
Fail "T6 bob_invalidToken: PATCH sollte 401 liefern"
}
catch {
if ($_ -match "401") { Pass "T6 bob_invalidToken: PATCH /api/inventory/items → 401" }
else { Fail "T6 bob_invalidToken: Unerwarteter Fehler bei PATCH: $_" }
}
}
catch { Fail "T6 bob_invalidToken: $_" }
# ---- T2: PUT mit leeren Listen → GET liefert 0 Items ----
try {
$emptyInventory = @{ version = 1; categories = @(); locations = @(); items = @(); settings = @() }
Invoke-Api -Method PUT -Path "/api/inventory" -Body $emptyInventory -Token $bobTokens.accessToken | Out-Null
$emptyFetch = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
if ($emptyFetch.items.Count -eq 0) {
Pass "T2 bob_pushEmptyInventory: PUT leer → GET liefert 0 Items"
}
else {
Fail "T2 bob_pushEmptyInventory: Erwartet 0 Items, erhalten $($emptyFetch.items.Count)"
}
}
catch { Fail "T2 bob_pushEmptyInventory: $_" }
# ---- T3: Zwei PUTs hintereinander → Zweites ueberschreibt ----
try {
$firstId = [System.Guid]::NewGuid().ToString()
$secondId = [System.Guid]::NewGuid().ToString()
$firstPut = @{
version = 1
categories = @(@{ id = 20; name = "KatA" }); locations = @(@{ id = 20; name = "OrtA" })
items = @(@{ id = $firstId; name = "ErstesPUT"; categoryId = 20; quantity = 1.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 20; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() })
settings = @()
}
$secondPut = @{
version = 1
categories = @(@{ id = 21; name = "KatB" }); locations = @(@{ id = 21; name = "OrtB" })
items = @(@{ id = $secondId; name = "ZweitesPUT"; categoryId = 21; quantity = 2.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 21; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() })
settings = @()
}
Invoke-Api -Method PUT -Path "/api/inventory" -Body $firstPut -Token $bobTokens.accessToken | Out-Null
Invoke-Api -Method PUT -Path "/api/inventory" -Body $secondPut -Token $bobTokens.accessToken | Out-Null
$t3Fetch = Invoke-Api -Method GET -Path "/api/inventory" -Token $bobTokens.accessToken
$hasFirst = @($t3Fetch.items | Where-Object { $_.id -eq $firstId }).Count -gt 0
$hasSecond = @($t3Fetch.items | Where-Object { $_.id -eq $secondId }).Count -gt 0
if (-not $hasFirst -and $hasSecond) {
Pass "T3 bob_putOverwritesPreviousData: Zweites PUT ueberschreibt erstes"
}
else {
Fail "T3 bob_putOverwritesPreviousData: hasFirst=$hasFirst, hasSecond=$hasSecond"
}
}
catch { Fail "T3 bob_putOverwritesPreviousData: $_" }
# ---- T4: Bob patcht selbst → empfaengt trotzdem WS-Event ----
$t4WS = $null
try {
$t4ItemId = [System.Guid]::NewGuid().ToString()
$t4Inv = @{
version = 1; categories = @(@{ id = 30; name = "KatT4" }); locations = @(@{ id = 30; name = "OrtT4" })
items = @(@{ id = $t4ItemId; name = "T4-Item"; categoryId = 30; quantity = 1.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 30; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() })
settings = @()
}
Invoke-Api -Method PUT -Path "/api/inventory" -Body $t4Inv -Token $bobTokens.accessToken | Out-Null
$t4WS = Open-WebSocket -token $bobTokens.accessToken
Start-Sleep -Milliseconds 800
$t4Patch = @{ id = $t4ItemId; name = "T4-Item"; categoryId = 30; quantity = 5.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 30; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
Invoke-Api -Method PATCH -Path "/api/inventory/items/$t4ItemId" -Body $t4Patch -Token $bobTokens.accessToken | Out-Null
$t4Events = Receive-WsMessages -ws $t4WS -waitSeconds 5
Info "WS-Events T4: $($t4Events.Count) Event(s) - $($t4Events | ConvertTo-Json -Compress)"
if (@($t4Events | Where-Object { $_.type -eq "inventoryUpdated" }).Count -gt 0) {
Pass "T4 bob_websocketReceivesOwnPatch: Bob empfaengt inventoryUpdated nach eigenem PATCH"
}
else {
Fail "T4 bob_websocketReceivesOwnPatch: Kein WS-Event empfangen (events: $($t4Events.Count), raw: $($t4Events | ConvertTo-Json -Compress))"
}
}
catch { Fail "T4 bob_websocketReceivesOwnPatch: $_" }
finally {
if ($t4WS) { Close-WebSocket -ws $t4WS; $t4WS = $null }
}
# ---- T7: Bob oeffnet 2 WS-Verbindungen → beide empfangen Events ----
$t7ws1 = $null
$t7ws2 = $null
try {
$t7ItemId = [System.Guid]::NewGuid().ToString()
$t7Inv = @{
version = 1; categories = @(@{ id = 40; name = "KatT7" }); locations = @(@{ id = 40; name = "OrtT7" })
items = @(@{ id = $t7ItemId; name = "T7-Item"; categoryId = 40; quantity = 1.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 40; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() })
settings = @()
}
Invoke-Api -Method PUT -Path "/api/inventory" -Body $t7Inv -Token $bobTokens.accessToken | Out-Null
$t7ws1 = Open-WebSocket -token $bobTokens.accessToken
$t7ws2 = Open-WebSocket -token $bobTokens.accessToken
Start-Sleep -Milliseconds 1200
$bobTokens4 = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $BobUser; password = $BobPassword }
$t7Patch = @{ id = $t7ItemId; name = "T7-Item"; categoryId = 40; quantity = 9.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 40; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
Invoke-Api -Method PATCH -Path "/api/inventory/items/$t7ItemId" -Body $t7Patch -Token $bobTokens4.accessToken | Out-Null
$t7ev1 = Receive-WsMessages -ws $t7ws1 -waitSeconds 5
$t7ev2 = Receive-WsMessages -ws $t7ws2 -waitSeconds 5
Info "WS-Events T7-WS1: $($t7ev1.Count) - $($t7ev1 | ConvertTo-Json -Compress)"
Info "WS-Events T7-WS2: $($t7ev2.Count) - $($t7ev2 | ConvertTo-Json -Compress)"
$got1 = @($t7ev1 | Where-Object { $_.type -eq "inventoryUpdated" }).Count -gt 0
$got2 = @($t7ev2 | Where-Object { $_.type -eq "inventoryUpdated" }).Count -gt 0
if ($got1 -and $got2) {
Pass "T7 bob_multipleWebSocketSessions: Beide WS-Sessions empfangen inventoryUpdated"
}
else {
Fail "T7 bob_multipleWebSocketSessions: WS1=$got1, WS2=$got2"
}
}
catch { Fail "T7 bob_multipleWebSocketSessions: $_" }
finally {
if ($t7ws1) { Close-WebSocket -ws $t7ws1; $t7ws1 = $null }
if ($t7ws2) { Close-WebSocket -ws $t7ws2; $t7ws2 = $null }
}
# ---- T8: Bob disconnected, dritter Client patcht → kein Server-Fehler ----
try {
$t8ItemId = [System.Guid]::NewGuid().ToString()
$t8Inv = @{
version = 1; categories = @(@{ id = 50; name = "KatT8" }); locations = @(@{ id = 50; name = "OrtT8" })
items = @(@{ id = $t8ItemId; name = "T8-Item"; categoryId = 50; quantity = 1.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 50; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() })
settings = @()
}
Invoke-Api -Method PUT -Path "/api/inventory" -Body $t8Inv -Token $bobTokens.accessToken | Out-Null
$t8ws = Open-WebSocket -token $bobTokens.accessToken
Close-WebSocket -ws $t8ws; $t8ws = $null
Start-Sleep -Milliseconds 600
$bobTokens5 = Invoke-Api -Method POST -Path "/api/auth/login" -Body @{ username = $BobUser; password = $BobPassword }
$t8Patch = @{ id = $t8ItemId; name = "T8-Item"; categoryId = 50; quantity = 7.0; unit = "Stueck"; unitPrice = 0.0; kcalPerUnit = $null; expiryDate = $null; locationId = 50; notes = ""; lastUpdated = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
try {
Invoke-Api -Method PATCH -Path "/api/inventory/items/$t8ItemId" -Body $t8Patch -Token $bobTokens5.accessToken | Out-Null
Pass "T8 bob_patchAfterDisconnect: PATCH nach Bob-Disconnect liefert keinen Server-Fehler"
}
catch { Fail "T8 bob_patchAfterDisconnect: PATCH fehlgeschlagen - $_" }
}
catch { Fail "T8 bob_patchAfterDisconnect: $_" }
}
# ---------------------------------------------------------------------------
# Ergebnis
# ---------------------------------------------------------------------------
$total = $script:PassCount + $script:FailCount
Write-Host ""
Write-Host "======================================" -ForegroundColor White
Write-Host "ERGEBNIS: $($script:PassCount)/$total Tests bestanden" -ForegroundColor $(if ($script:FailCount -eq 0) { "Green" } else { "Yellow" })
Write-Host "======================================" -ForegroundColor White
if ($script:FailCount -gt 0) { exit 1 } else { exit 0 }