diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4193bc5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,25 @@ +{ + "permissions": { + "allow": [ + "Bash(curl:*)", + "Bash(mvn clean compile:*)", + "Bash(mvn test:*)", + "Bash(tee:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(mvn clean test:*)", + "Bash(timeout 60 mvn test:*)", + "Bash(grep:*)", + "Bash(echo:*)", + "Bash(find:*)", + "Bash(ls:*)", + "Bash(pkill:*)", + "Bash(lsof:*)", + "Bash(xargs kill:*)", + "Bash(while ps aux)", + "Bash(do sleep 5)", + "Bash(done)", + "Bash(tail:*)" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/backend-quarkus/.gitignore b/backend-quarkus/.gitignore new file mode 100644 index 0000000..b0718bb --- /dev/null +++ b/backend-quarkus/.gitignore @@ -0,0 +1,36 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IntelliJ IDEA +.idea/ +*.iml +*.iws +*.ipr + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# VS Code +.vscode/ + +# Quarkus +.quarkus/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/backend-quarkus/FUNCTIONAL-SPEC.md b/backend-quarkus/FUNCTIONAL-SPEC.md new file mode 100644 index 0000000..01d4d98 --- /dev/null +++ b/backend-quarkus/FUNCTIONAL-SPEC.md @@ -0,0 +1,1182 @@ +# Funktionell Specifikation: Pump-Foil-Game Backend + +## Översikt + +Detta dokument beskriver datamodellen, use cases och beteende för Pump-Foil-Game backend-systemet. Systemet hanterar en tävling där deltagare (contestants) ställer sig i kö, spelar spelet och får sina resultat registrerade på en ledartavla. + +--- + +## 1. Domänmodell - Klassdiagram + +### 1.1 Domain Layer + +```plantuml +@startuml Domain Model + +' Styling +skinparam class { + BackgroundColor<> LightYellow + BackgroundColor<> LightBlue + BackgroundColor<> LightGreen + BackgroundColor<> LightGray +} + +package "domain.model" { + class Contestant <> { + - email: String {id} + - name: String + -- + + Contestant(email: String, name: String) + + getEmail(): String + + getName(): String + + equals(other: Object): boolean + + hashCode(): int + } + + class QueueItem <> { + - timestamp: long {id} + - contestant: Contestant + -- + + QueueItem(timestamp: long, contestant: Contestant) + + getTimestamp(): long + + getContestant(): Contestant + } + + class LeaderboardEntry <> { + - contestant: Contestant + - result: GameResult + -- + + LeaderboardEntry(contestant: Contestant, result: GameResult) + + getContestant(): Contestant + + getResult(): GameResult + + updateResult(newResult: GameResult): LeaderboardEntry + } + + class GameResult <> { + - splitTime: double + - endTime: double + -- + + GameResult(splitTime: double, endTime: double) + + getSplitTime(): double + + getEndTime(): double + + isFasterThan(other: GameResult): boolean + } + + enum GameStateType { + IDLE + IN_PROGRESS + } + + class GameState <> { + - type: GameStateType + - currentContestant: Optional + -- + + {static} idle(): GameState + + {static} inProgress(contestant: Contestant): GameState + + isIdle(): boolean + + isInProgress(): boolean + + getCurrentContestant(): Optional + } + + class GameStartedEvent <> { + - contestant: Contestant + - timestamp: long + } + + class GameFinishedEvent <> { + - result: GameResult + - timestamp: long + } + + class GameAbortedEvent <> { + - timestamp: long + } +} + +package "domain.service" { + class GameService <> { + - currentState: GameState + -- + + startGame(timestamp: Optional, queueRepo: QueueRepository): QueueItem + + finishGame(result: GameResult, leaderboardRepo: LeaderboardRepository): void + + abortGame(): void + + getCurrentState(): GameState + } + + class LeaderboardService <> { + -- + + getSortedLeaderboard(repo: LeaderboardRepository): List + + updateOrAddEntry(contestant: Contestant, result: GameResult, repo: LeaderboardRepository): void + } +} + +package "domain.port" { + interface ContestantRepository <> { + + findAll(): List + + findEnqueued(): List + + findNotEnqueued(): List + + findByEmail(email: String): Optional + + save(contestant: Contestant): Contestant + + delete(email: String): void + } + + interface QueueRepository <> { + + findAll(): List + + findByTimestamp(timestamp: long): Optional + + save(queueItem: QueueItem): QueueItem + + delete(timestamp: long): void + + deleteAll(): void + } + + interface LeaderboardRepository <> { + + findAllSortedByEndTime(): List + + findByEmail(email: String): Optional + + save(entry: LeaderboardEntry): LeaderboardEntry + + deleteAll(): void + } + + interface NotificationService <> { + + notifyGameStarted(contestant: Contestant): void + + notifyGameFinished(result: GameResult): void + + notifyGameAborted(): void + } +} + +' Relationships +LeaderboardEntry *-- GameResult : contains +LeaderboardEntry *-- Contestant : contains +QueueItem *-- Contestant : contains +GameState *-- GameStateType : has +GameState o-- Contestant : may contain + +GameService --> GameState : manages +GameService ..> QueueRepository : uses +GameService ..> LeaderboardRepository : uses +GameService ..> GameStartedEvent : emits +GameService ..> GameFinishedEvent : emits +GameService ..> GameAbortedEvent : emits + +LeaderboardService ..> LeaderboardRepository : uses +LeaderboardService ..> LeaderboardEntry : creates + +note right of Contestant + **Aggregate Root** + Identity: email + Immutable +end note + +note right of GameResult + **Value Object** + Immutable + Validation: endTime >= splitTime +end note + +note right of GameState + **Value Object** + Immutable + Factory methods for states +end note + +@enduml +``` + +### 1.2 Kardinalitet och Regler + +| Relation | Kardinalitet | Regel | +|----------|--------------|-------| +| Contestant → QueueItem | 0..1 | En contestant kan finnas i kön max en gång | +| Contestant → LeaderboardEntry | 0..1 | En contestant kan ha max en post i ledartavlan | +| GameState → Contestant | 0..1 | Max ett spel kan vara aktivt åt gången | +| QueueItem → Contestant | 1 | Varje köpost måste ha en contestant | +| LeaderboardEntry → Contestant | 1 | Varje ledartavla-post måste ha en contestant | +| LeaderboardEntry → GameResult | 1 | Varje ledartavla-post måste ha ett resultat | + +--- + +## 2. Use Cases + +### 2.1 UC1: Lägg till Contestant + +**Aktör:** System/Användare + +**Precondition:** +- Email är unik (finns inte redan) +- Email och name är icke-tomma strängar + +**Postcondition:** +- En ny Contestant är sparad i systemet +- Contestant finns INTE i kön automatiskt + +**Sekvensdiagram:** + +```plantuml +@startuml Add Contestant + +actor Client +participant ContestantResource +participant ContestantAppService +participant ContestantRepository +database MongoDB + +Client -> ContestantResource: POST /contestants\n{email, name} +activate ContestantResource + +ContestantResource -> ContestantAppService: addContestant(dto) +activate ContestantAppService + +ContestantAppService -> ContestantRepository: findByEmail(email) +activate ContestantRepository +ContestantRepository -> MongoDB: query +MongoDB --> ContestantRepository: not found +ContestantRepository --> ContestantAppService: Optional.empty() +deactivate ContestantRepository + +ContestantAppService -> ContestantAppService: create Contestant entity +ContestantAppService -> ContestantRepository: save(contestant) +activate ContestantRepository +ContestantRepository -> MongoDB: insert +MongoDB --> ContestantRepository: success +ContestantRepository --> ContestantAppService: contestant +deactivate ContestantRepository + +ContestantAppService --> ContestantResource: contestantDTO +deactivate ContestantAppService + +ContestantResource --> Client: 200 OK\n{email, name} +deactivate ContestantResource + +@enduml +``` + +**State Transitions:** + +```plantuml +@startuml Contestant State + +[*] --> NotExists : System start + +NotExists --> Exists : POST /contestants +Exists --> Exists : Update name\n(not implemented) +Exists --> NotExists : DELETE /contestants/:email + +note right of Exists + Contestant kan vara: + - I kön (enqueued) + - Inte i kön (not enqueued) + - På ledartavlan (has score) +end note + +@enduml +``` + +--- + +### 2.2 UC2: Lägg till i Kö + +**Aktör:** System/Användare + +**Precondition:** +- Contestant finns i systemet +- Contestant finns INTE redan i kön + +**Postcondition:** +- En ny QueueItem är skapad med current timestamp +- QueueItem pekar på Contestant +- Contestant är nu "enqueued" + +**Sekvensdiagram:** + +```plantuml +@startuml Add to Queue + +actor Client +participant QueueResource +participant QueueAppService +participant ContestantRepository +participant QueueRepository +database MongoDB + +Client -> QueueResource: POST /queue\n{email, name} +activate QueueResource + +QueueResource -> QueueAppService: addToQueue(dto) +activate QueueAppService + +QueueAppService -> ContestantRepository: findByEmail(email) +activate ContestantRepository +ContestantRepository -> MongoDB: query +MongoDB --> ContestantRepository: contestant found +ContestantRepository --> QueueAppService: Optional +deactivate ContestantRepository + +alt Contestant not found + QueueAppService -> ContestantRepository: save(new Contestant) + activate ContestantRepository + ContestantRepository -> MongoDB: insert + ContestantRepository --> QueueAppService: contestant + deactivate ContestantRepository +end + +QueueAppService -> QueueAppService: timestamp = System.currentTimeMillis() +QueueAppService -> QueueAppService: create QueueItem(timestamp, contestant) + +QueueAppService -> QueueRepository: save(queueItem) +activate QueueRepository +QueueRepository -> MongoDB: insert +MongoDB --> QueueRepository: success +QueueRepository --> QueueAppService: queueItem +deactivate QueueRepository + +QueueAppService --> QueueResource: queueItemDTO +deactivate QueueAppService + +QueueResource --> Client: 200 OK\n{timestamp, contestant} +deactivate QueueResource + +@enduml +``` + +**State Transitions:** + +```plantuml +@startuml Queue State + +[*] --> Empty : System start + +Empty --> HasItems : Add first contestant +HasItems --> HasItems : Add more contestants +HasItems --> Empty : Remove last contestant + +state HasItems { + [*] --> Contestant1 + Contestant1 --> Contestant2 : Add contestant + Contestant2 --> Contestant1 : Remove contestant + + note right of Contestant1 + Sorterad efter timestamp + (äldst först) + end note +} + +@enduml +``` + +--- + +### 2.3 UC3: Starta Spel + +**Aktör:** System/Användare + +**Precondition:** +- Inget spel pågår (GameState.isIdle() == true) +- Det finns minst en contestant i kön (om timestamp ej angiven) +- OM timestamp angiven: QueueItem med den timestamp:en finns + +**Postcondition:** +- GameState ändrad till IN_PROGRESS +- QueueItem borttagen från kön +- GameStartedEvent emitterad +- WebSocket-notification skickad till alla klienter + +**Sekvensdiagram:** + +```plantuml +@startuml Start Game + +actor Client +participant GameResource +participant GameAppService +participant GameService +participant QueueRepository +participant NotificationService +participant WebSocket + +Client -> GameResource: POST /game-start\n[{timestamp}] +activate GameResource + +GameResource -> GameAppService: startGame(Optional) +activate GameAppService + +GameAppService -> GameService: getCurrentState() +GameService --> GameAppService: GameState.IDLE + +alt timestamp provided + GameAppService -> QueueRepository: findByTimestamp(timestamp) +else no timestamp + GameAppService -> QueueRepository: findAll() + GameAppService -> GameAppService: select first item (sorted by timestamp) +end + +activate QueueRepository +QueueRepository --> GameAppService: Optional +deactivate QueueRepository + +alt QueueItem found + GameAppService -> GameService: startGame(queueItem) + activate GameService + GameService -> GameService: currentState = GameState.inProgress(contestant) + GameService --> GameAppService: queueItem + deactivate GameService + + GameAppService -> QueueRepository: delete(queueItem.timestamp) + activate QueueRepository + QueueRepository --> GameAppService: success + deactivate QueueRepository + + GameAppService -> NotificationService: notifyGameStarted(contestant) + activate NotificationService + NotificationService -> WebSocket: broadcast InitGame message + WebSocket --> NotificationService: success + deactivate NotificationService + + GameAppService --> GameResource: queueItemDTO +else No queue items + GameAppService --> GameResource: 400 Bad Request +end + +deactivate GameAppService + +GameResource --> Client: 200 OK\n{timestamp, contestant} +deactivate GameResource + +@enduml +``` + +**State Transitions:** + +```plantuml +@startuml Game State Transitions + +[*] --> IDLE : System start + +IDLE --> IN_PROGRESS : startGame() +IN_PROGRESS --> IDLE : finishGame() +IN_PROGRESS --> IDLE : abortGame() + +note right of IN_PROGRESS + Innehåller: + - currentContestant + - start timestamp +end note + +note left of IDLE + Ingen aktiv spelare + Redo för nytt spel +end note + +@enduml +``` + +--- + +### 2.4 UC4: Avsluta Spel + +**Aktör:** System/Användare + +**Precondition:** +- Ett spel pågår (GameState.isInProgress() == true) +- GameResult är valid (endTime >= splitTime) + +**Postcondition:** +- GameState återställd till IDLE +- LeaderboardEntry skapad eller uppdaterad +- OM nytt resultat är bättre: LeaderboardEntry uppdaterad +- GameFinishedEvent emitterad +- WebSocket-notification skickad + +**Sekvensdiagram:** + +```plantuml +@startuml Finish Game + +actor Client +participant GameResource +participant GameAppService +participant GameService +participant LeaderboardService +participant LeaderboardRepository +participant NotificationService +participant WebSocket + +Client -> GameResource: POST /game-finish\n{splitTime, endTime} +activate GameResource + +GameResource -> GameAppService: finishGame(resultDTO) +activate GameAppService + +GameAppService -> GameService: getCurrentState() +GameService --> GameAppService: GameState.IN_PROGRESS(contestant) + +alt Game is in progress + GameAppService -> GameService: finishGame(result) + activate GameService + GameService -> GameService: currentState = GameState.idle() + GameService --> GameAppService: success + deactivate GameService + + GameAppService -> LeaderboardService: updateOrAddEntry(contestant, result, repo) + activate LeaderboardService + + LeaderboardService -> LeaderboardRepository: findByEmail(contestant.email) + activate LeaderboardRepository + LeaderboardRepository --> LeaderboardService: Optional + deactivate LeaderboardRepository + + alt Entry exists + LeaderboardService -> LeaderboardService: check if new result is better + alt New result is better + LeaderboardService -> LeaderboardService: update entry with new result + LeaderboardService -> LeaderboardRepository: save(updatedEntry) + else New result is worse + LeaderboardService -> LeaderboardService: keep existing result + end + else Entry does not exist + LeaderboardService -> LeaderboardService: create new entry + LeaderboardService -> LeaderboardRepository: save(newEntry) + end + + activate LeaderboardRepository + LeaderboardRepository --> LeaderboardService: saved entry + deactivate LeaderboardRepository + deactivate LeaderboardService + + GameAppService -> NotificationService: notifyGameFinished(result) + activate NotificationService + NotificationService -> WebSocket: broadcast EndGame message + WebSocket --> NotificationService: success + deactivate NotificationService + + GameAppService --> GameResource: success +else No game in progress + GameAppService --> GameResource: 400 Bad Request +end + +deactivate GameAppService + +GameResource --> Client: 200 OK +deactivate GameResource + +@enduml +``` + +**Leaderboard Update Logic:** + +```plantuml +@startuml Leaderboard Update Decision + +start + +:Receive new GameResult; + +if (Entry exists for contestant?) then (yes) + :Get existing entry; + if (new endTime < existing endTime?) then (yes) + :Update entry with new result; + :Save updated entry; + else (no) + :Keep existing result; + :Discard new result; + endif +else (no) + :Create new entry; + :Save new entry; +endif + +:Return updated/new entry; + +stop + +@enduml +``` + +--- + +### 2.5 UC5: Avbryt Spel + +**Aktör:** System/Användare + +**Precondition:** +- Ett spel pågår (GameState.isInProgress() == true) + +**Postcondition:** +- GameState återställd till IDLE +- INGET resultat sparat på ledartavlan +- GameAbortedEvent emitterad +- WebSocket-notification skickad + +**Sekvensdiagram:** + +```plantuml +@startuml Abort Game + +actor Client +participant GameResource +participant GameAppService +participant GameService +participant NotificationService +participant WebSocket + +Client -> GameResource: POST /game-abort +activate GameResource + +GameResource -> GameAppService: abortGame() +activate GameAppService + +GameAppService -> GameService: getCurrentState() +GameService --> GameAppService: GameState.IN_PROGRESS(contestant) + +alt Game is in progress + GameAppService -> GameService: abortGame() + activate GameService + GameService -> GameService: currentState = GameState.idle() + GameService --> GameAppService: success + deactivate GameService + + GameAppService -> NotificationService: notifyGameAborted() + activate NotificationService + NotificationService -> WebSocket: broadcast AbortGame message + WebSocket --> NotificationService: success + deactivate NotificationService + + GameAppService --> GameResource: success +else No game in progress + GameAppService --> GameResource: 400 Bad Request +end + +deactivate GameAppService + +GameResource --> Client: 200 OK +deactivate GameResource + +@enduml +``` + +--- + +### 2.6 UC6: Hämta Ledartavla + +**Aktör:** System/Användare + +**Precondition:** +- Ingen (tom ledartavla är valid) + +**Postcondition:** +- Lista av LeaderboardEntry returnerad, sorterad efter endTime (lägst först) + +**Sekvensdiagram:** + +```plantuml +@startuml Get Leaderboard + +actor Client +participant LeaderboardResource +participant LeaderboardAppService +participant LeaderboardService +participant LeaderboardRepository + +Client -> LeaderboardResource: GET /leaderboard +activate LeaderboardResource + +LeaderboardResource -> LeaderboardAppService: getLeaderboard() +activate LeaderboardAppService + +LeaderboardAppService -> LeaderboardService: getSortedLeaderboard(repo) +activate LeaderboardService + +LeaderboardService -> LeaderboardRepository: findAllSortedByEndTime() +activate LeaderboardRepository +LeaderboardRepository --> LeaderboardService: List +deactivate LeaderboardRepository + +LeaderboardService --> LeaderboardAppService: sorted entries +deactivate LeaderboardService + +LeaderboardAppService --> LeaderboardResource: List +deactivate LeaderboardAppService + +LeaderboardResource --> Client: 200 OK\n[{contestant, result}] +deactivate LeaderboardResource + +@enduml +``` + +--- + +### 2.7 UC7: Ta bort Contestant + +**Aktör:** System/Användare + +**Precondition:** +- Contestant finns i systemet +- Contestant är INTE i ett pågående spel + +**Postcondition:** +- Contestant borttagen från systemet +- OM contestant fanns i kö: QueueItem borttagen +- OM contestant fanns på ledartavla: Posten kvar MEN med tom name (Go-bug) eller tas bort (Quarkus fix) + +**Sekvensdiagram:** + +```plantuml +@startuml Delete Contestant + +actor Client +participant ContestantResource +participant ContestantAppService +participant GameService +participant ContestantRepository +participant QueueRepository + +Client -> ContestantResource: DELETE /contestants/:email +activate ContestantResource + +ContestantResource -> ContestantAppService: deleteContestant(email) +activate ContestantAppService + +ContestantAppService -> GameService: getCurrentState() +GameService --> ContestantAppService: currentState + +alt Contestant is currently playing + ContestantAppService --> ContestantResource: 400 Bad Request\n"Cannot delete active player" +else Contestant is not playing + ContestantAppService -> ContestantRepository: findByEmail(email) + activate ContestantRepository + ContestantRepository --> ContestantAppService: Optional + deactivate ContestantRepository + + alt Contestant found + ContestantAppService -> QueueRepository: findAll() + activate QueueRepository + QueueRepository --> ContestantAppService: List + deactivate QueueRepository + + ContestantAppService -> ContestantAppService: filter items for this contestant + + loop for each matching queue item + ContestantAppService -> QueueRepository: delete(timestamp) + end + + ContestantAppService -> ContestantRepository: delete(email) + activate ContestantRepository + ContestantRepository --> ContestantAppService: success + deactivate ContestantRepository + + ContestantAppService --> ContestantResource: success + else Contestant not found + ContestantAppService --> ContestantResource: 404 Not Found + end +end + +deactivate ContestantAppService + +ContestantResource --> Client: 200 OK +deactivate ContestantResource + +@enduml +``` + +--- + +## 3. Data State Transitions + +### 3.1 Complete Game Flow State Machine + +```plantuml +@startuml Complete Game Flow + +[*] --> EmptySystem : System initialization + +state EmptySystem { + [*] --> NoContestants + NoContestants --> HasContestants : Add contestant +} + +EmptySystem --> SystemWithData : Add contestant + +state SystemWithData { + state "Contestants Exist" as CE + state "Queue Empty" as QE + state "Queue Has Items" as QH + state "Game Idle" as GI + state "Game In Progress" as GP + state "Leaderboard Empty" as LE + state "Leaderboard Has Entries" as LH + + [*] --> CE + CE --> QE : Initial state + QE --> QH : Add to queue + QH --> QE : Remove all from queue + + GI --> GP : Start game (takes from queue) + GP --> GI : Finish game (saves to leaderboard) + GP --> GI : Abort game (no save) + + LE --> LH : First game finished + LH --> LH : More games finished + + note right of GP + Only one game can + be in progress + end note + + note left of LH + Sorted by endTime + Best score first + end note +} + +SystemWithData --> EmptySystem : Delete all contestants + +@enduml +``` + +### 3.2 Contestant Lifecycle + +```plantuml +@startuml Contestant Lifecycle + +[*] --> Created : POST /contestants + +state Created { + [*] --> NotInQueue + NotInQueue --> InQueue : POST /queue + InQueue --> NotInQueue : DELETE /queue + InQueue --> Playing : POST /game-start +} + +Created --> Deleted : DELETE /contestants + +state Playing { + [*] --> GameActive + GameActive --> GameCompleted : POST /game-finish + GameActive --> GameAborted : POST /game-abort +} + +GameCompleted --> HasScore : Result saved to leaderboard +GameAborted --> NoScore : No result saved + +HasScore --> BetterScore : POST /game-finish\n(with better time) +BetterScore --> HasScore : Score updated +HasScore --> HasScore : POST /game-finish\n(with worse time) + +Deleted --> [*] + +note right of HasScore + Score persists even if + contestant is deleted + (Go bug to fix in Quarkus) +end note + +@enduml +``` + +--- + +## 4. WebSocket Notifications + +### 4.1 Notification Flow + +```plantuml +@startuml WebSocket Notification Flow + +participant "WebSocket Client 1" as WS1 +participant "WebSocket Client 2" as WS2 +participant GameNotificationWebSocket +participant GameService +participant NotificationService + +WS1 -> GameNotificationWebSocket: connect (ws://.../ws) +activate GameNotificationWebSocket +GameNotificationWebSocket -> GameNotificationWebSocket: add to sessions set +GameNotificationWebSocket --> WS1: connected +deactivate GameNotificationWebSocket + +WS2 -> GameNotificationWebSocket: connect (ws://.../ws) +activate GameNotificationWebSocket +GameNotificationWebSocket -> GameNotificationWebSocket: add to sessions set +GameNotificationWebSocket --> WS2: connected +deactivate GameNotificationWebSocket + +... Game events occur ... + +GameService -> NotificationService: notifyGameStarted(contestant) +activate NotificationService +NotificationService -> GameNotificationWebSocket: broadcast +activate GameNotificationWebSocket + +GameNotificationWebSocket -> GameNotificationWebSocket: create InitGame message\n{type, userid, name} + +par Broadcast to all clients + GameNotificationWebSocket -> WS1: send message + GameNotificationWebSocket -> WS2: send message +end + +GameNotificationWebSocket --> NotificationService: done +deactivate GameNotificationWebSocket +deactivate NotificationService + +@enduml +``` + +### 4.2 Notification Types + +```plantuml +@startuml Notification Types + +class GameNotificationDTO { + + type: String + + userid: String (optional) + + name: String (optional) + + splitTime: Double (optional) + + finishTime: Double (optional) +} + +note right of GameNotificationDTO + **InitGame** + - type: "InitGame" + - userid: contestant email + - name: contestant name + - splitTime: null + - finishTime: null + + **EndGame** + - type: "EndGame" + - userid: null + - name: null + - splitTime: result split time + - finishTime: result end time + + **AbortGame** + - type: "AbortGame" + - userid: null + - name: null + - splitTime: null + - finishTime: null +end note + +@enduml +``` + +--- + +## 5. Business Rules + +### 5.1 Validerings-regler + +| Regel | Beskrivning | Konsekvens vid brott | +|-------|-------------|---------------------| +| **BR1** | Email får inte vara null eller blank | IllegalArgumentException | +| **BR2** | Name får inte vara null (men kan vara blank för Go-kompatibilitet) | IllegalArgumentException | +| **BR3** | Email måste vara unik i systemet | 409 Conflict | +| **BR4** | Contestant kan bara finnas i kön en gång | 400 Bad Request | +| **BR5** | Endast ett spel kan pågå åt gången | 400 Bad Request | +| **BR6** | GameResult: endTime >= splitTime | IllegalArgumentException | +| **BR7** | Kan inte starta spel om kön är tom (och ingen timestamp angiven) | 400 Bad Request | +| **BR8** | Kan inte avsluta/avbryta spel om inget pågår | 400 Bad Request | +| **BR9** | Kan inte ta bort contestant som spelar just nu | 400 Bad Request | + +### 5.2 Ledartavla-regler + +```plantuml +@startuml Leaderboard Rules + +start + +:New game result received; + +if (Contestant has existing score?) then (yes) + if (New endTime < Existing endTime?) then (yes) + :Update with new (better) score; + :Keep best result; + else (no) + :Discard new (worse) score; + :Keep existing result; + endif +else (no) + :Create new leaderboard entry; + :Add to leaderboard; +endif + +:Sort leaderboard by endTime ASC; +:Return sorted leaderboard; + +stop + +note right + **Sorting Rule:** + - Primary: endTime (ascending) + - Lower time = better score + - Position 1 = fastest time +end note + +@enduml +``` + +### 5.3 Kö-regler + +- Kön är sorterad efter timestamp (FIFO - First In, First Out) +- När spel startas utan timestamp: välj äldsta posten (lägst timestamp) +- När spel startas med timestamp: välj specifik post +- Efter spelstart: valda köposten tas bort från kön + +--- + +## 6. Error Handling + +### 6.1 HTTP Status Codes + +| Status | Use Case | Exempel | +|--------|----------|---------| +| **200 OK** | Lyckad operation | GET, POST, DELETE lyckades | +| **400 Bad Request** | Ogiltig input eller ogiltigt tillstånd | Spel pågår redan, kön tom, invalid data | +| **404 Not Found** | Resurs finns inte | Contestant/QueueItem ej hittad | +| **409 Conflict** | Resurs finns redan | Contestant med email finns redan | +| **500 Internal Server Error** | Systemfel | Database error, unexpected exception | + +### 6.2 Error Response Format + +```json +{ + "error": "Beskrivning av felet", + "status": 400, + "timestamp": 1234567890, + "path": "/api/v1/game-start" +} +``` + +--- + +## 7. Data Invariants + +### 7.1 System Invariants + +Dessa regler måste alltid vara sanna i systemet: + +1. **GameState Invariant**: `GameService.currentState` är antingen IDLE eller IN_PROGRESS med exakt en contestant +2. **Queue Uniqueness**: Ingen contestant får förekomma mer än en gång i kön +3. **Leaderboard Uniqueness**: Varje contestant får max ha en post på ledartavlan +4. **Timestamp Uniqueness**: Varje QueueItem har unik timestamp +5. **Email Uniqueness**: Varje Contestant har unik email +6. **Result Validity**: För alla GameResult: `endTime >= splitTime` +7. **Leaderboard Best Score**: Ledartavlan innehåller alltid bästa resultatet för varje contestant + +### 7.2 Invariant Verification + +```plantuml +@startuml Invariant Checks + +start + +partition "Pre-Operation Checks" { + :Check input validity; + :Check business rules; + :Check system state; +} + +if (All checks pass?) then (yes) + partition "Execute Operation" { + :Modify state; + :Save to database; + } + + partition "Post-Operation Verification" { + :Verify invariants hold; + if (Invariants violated?) then (yes) + :Rollback transaction; + :Log error; + :Return 500 error; + else (no) + :Commit transaction; + :Return success; + endif + } +else (no) + :Return 400/409 error; +endif + +stop + +@enduml +``` + +--- + +## 8. Concurrency Considerations + +### 8.1 Race Conditions + +Potentiella race conditions att hantera: + +1. **Simultana game-start requests**: Endast en ska lyckas +2. **Queue modification under game start**: Använd transaktioner +3. **Leaderboard updates från flera trådar**: Atomiska updates + +### 8.2 Locking Strategy + +```plantuml +@startuml Locking Strategy + +participant Thread1 +participant Thread2 +participant GameService +database MongoDB + +Thread1 -> GameService: startGame() +activate GameService +GameService -> GameService: synchronized lock on currentState +note right: Exclusive access to game state + +Thread2 -> GameService: startGame() +activate GameService +GameService -> GameService: wait for lock +note right: Blocked until Thread1 releases + +GameService -> MongoDB: Remove from queue (atomic) +GameService -> GameService: Update currentState +GameService -> GameService: Release lock + +GameService --> Thread1: Success +deactivate GameService + +GameService -> GameService: Acquire lock +GameService -> GameService: Check state +GameService --> Thread2: Error: Game in progress +deactivate GameService + +@enduml +``` + +--- + +## 9. Migreringsnoteringar (Go → Quarkus) + +### 9.1 Buggar att fixa + +| Go-beteende | Quarkus-förbättring | +|-------------|---------------------| +| GET /contestants returnerar 400 när tom | Returnera 200 + tom array [] | +| DELETE /queue/{nonexistent} returnerar 200 | Returnera 404 Not Found | +| Leaderboard visar "" för deleted contestant | Implementera soft delete eller cascade | + +### 9.2 Funktionella förbättringar + +- **Transaktionell konsistens**: Använd @Transactional för atomiska operationer +- **Validering**: Bean Validation på alla inputs +- **Felhantering**: Strukturerade felmeddelanden med context +- **Logging**: Strukturerad loggning av alla operationer +- **Metrics**: Quarkus metrics för monitoring + +--- + +## Sammanfattning + +Detta dokument beskriver den kompletta funktionella specifikationen för Pump-Foil-Game backend. Alla use cases, state transitions och business rules är dokumenterade med PlantUML-diagram för visualisering. + +**Nyckelkomponenter:** +- 3 huvudentiteter (Contestant, QueueItem, LeaderboardEntry) +- 2 value objects (GameResult, GameState) +- 7 huvudsakliga use cases +- 9 business rules +- 7 system invariants + +**Nästa steg:** Implementera enligt MIGRATION-TO-QUARKUS.PLAN.md och MIGRATION-TO-QUARKUS.TODO.md diff --git a/backend-quarkus/MIGRATION-TO-QUARKUS.PLAN.md b/backend-quarkus/MIGRATION-TO-QUARKUS.PLAN.md new file mode 100644 index 0000000..b4930dc --- /dev/null +++ b/backend-quarkus/MIGRATION-TO-QUARKUS.PLAN.md @@ -0,0 +1,422 @@ +# Migration Plan: Go Backend → Quarkus 3.x med Java 25 + +## Översikt + +Detta dokument beskriver planen för att migrera Pump-Foil-Game backend från Go till Quarkus 3.x med Java 25, enligt TDD-principer och Hexagonal Architecture. + +## Fas 1: Testsvit mot Go-backend ✅ KLAR + +### Status: Alla 49 tester gröna + +En komplett testsvit har skapats som testar den befintliga Go-backenden. Dessa tester fungerar som "characterization tests" som dokumenterar nuvarande beteende. + +**Teststruktur:** +- `ContestantApiTest` (9 tester): CRUD-operationer för contestants +- `QueueApiTest` (6 tester): Köhantering och filtrering +- `GameApiTest` (10 tester): Spellivscykel (start, finish, abort) +- `LeaderboardApiTest` (7 tester): Ledartavla med sortering och uppdateringar +- `FullGameFlowIntegrationTest` (11 tester): Komplett spelflöde +- `WebSocketApiTest` (6 tester): WebSocket-notifieringar + +**Teknisk stack för tester:** +- Java 25 med Records för immutable DTOs +- RestAssured för REST API-testning +- Jakarta WebSocket Client för WebSocket-testning +- JUnit 5 med @QuarkusTest +- AssertJ för fluent assertions +- MongoDB direktanslutning (localhost:27017) + +### Dokumenterade Go-backend buggar + +Dessa ska fixas i Quarkus-implementationen: + +1. **GET /contestants med tom databas** + - **Nuvarande:** Returnerar 400 med `{"error":"invalid filter"}` + - **Önskat:** Returnera 200 med tom array `[]` + +2. **DELETE /queue/{timestamp} för icke-existerande post** + - **Nuvarande:** Returnerar 200 + - **Önskat:** Returnera 404 Not Found + +3. **Contestant.name i leaderboard efter deletion** + - **Nuvarande:** Returnerar tom sträng `""` för name + - **Önskat:** Implementera soft delete eller cascade delete + +--- + +## Fas 2: Quarkus-implementation med TDD + +### Arkitektur: Hexagonal Architecture (Ports & Adapters) + +``` +src/main/java/se/pumfoilgame/ +├── domain/ # Domänlager (inga yttre beroenden) +│ ├── model/ # Entiteter och Value Objects +│ │ ├── Contestant.java # Entity - unik identitet (email) +│ │ ├── QueueItem.java # Entity - kö-post med timestamp +│ │ ├── LeaderboardEntry.java # Entity - ledartavla-post +│ │ ├── GameResult.java # Value Object - immutable resultat +│ │ └── GameState.java # Value Object - spelstatus (enum-liknande) +│ ├── service/ # Domain Services +│ │ ├── GameService.java # Spellogik (start, finish, abort) +│ │ └── LeaderboardService.java # Ledartavla-logik (sortering, uppdatering) +│ └── port/ # Portar (interfaces mot utsidan) +│ ├── ContestantRepository.java +│ ├── QueueRepository.java +│ ├── LeaderboardRepository.java +│ └── NotificationService.java +│ +├── application/ # Applikationslager (use case orchestration) +│ ├── ContestantApplicationService.java +│ ├── QueueApplicationService.java +│ ├── GameApplicationService.java +│ └── LeaderboardApplicationService.java +│ +├── infrastructure/ # Infrastrukturlager (tekniska adapters) +│ ├── persistence/ # MongoDB implementation +│ │ ├── entity/ # MongoDB entities med Panache +│ │ │ ├── ContestantEntity.java +│ │ │ ├── QueueItemEntity.java +│ │ │ └── LeaderboardEntryEntity.java +│ │ ├── repository/ # Repository implementations +│ │ │ ├── MongoContestantRepository.java +│ │ │ ├── MongoQueueRepository.java +│ │ │ └── MongoLeaderboardRepository.java +│ │ └── mapper/ # Domain ↔ Entity mappers +│ │ ├── ContestantMapper.java +│ │ ├── QueueItemMapper.java +│ │ └── LeaderboardEntryMapper.java +│ ├── websocket/ # WebSocket adapter +│ │ └── GameNotificationWebSocket.java +│ └── rest/ # REST API adapter +│ ├── ContestantResource.java +│ ├── QueueResource.java +│ ├── GameResource.java +│ └── LeaderboardResource.java +│ +└── config/ # Konfiguration och CDI setup + ├── ApplicationConfig.java + └── ExceptionMappers.java +``` + +### Implementation-ordning (TDD-driven) + +#### Steg 1: Domänmodell (Domain Layer) + +**Entities:** +- `Contestant` - Unik identitet via email + - email: String (identifier) + - name: String + - Validering: email not blank, name not blank + +- `QueueItem` - Kö-post + - timestamp: long (identifier) + - contestant: Contestant + +- `LeaderboardEntry` - Ledartavla-post + - contestant: Contestant + - result: GameResult + +**Value Objects:** +- `GameResult` - Immutable resultat + - splitTime: double + - endTime: double + - Validering: endTime >= splitTime + +- `GameState` - Spelstatus + - IDLE: Inget spel pågår + - IN_PROGRESS: Spel pågår med specifik contestant + - Innehåller: currentContestant (optional) + +**Domain Events:** +- `GameStartedEvent` +- `GameFinishedEvent` +- `GameAbortedEvent` + +#### Steg 2: Repository Ports (Domain Layer) + +Definiera interfaces utan implementationer: + +```java +public interface ContestantRepository { + List findAll(); + List findEnqueued(); + List findNotEnqueued(); + Optional findByEmail(String email); + Contestant save(Contestant contestant); + void delete(String email); +} + +public interface QueueRepository { + List findAll(); + Optional findByTimestamp(long timestamp); + QueueItem save(QueueItem queueItem); + void delete(long timestamp); + void deleteAll(); +} + +public interface LeaderboardRepository { + List findAllSortedByEndTime(); + Optional findByEmail(String email); + LeaderboardEntry save(LeaderboardEntry entry); + void deleteAll(); +} + +public interface NotificationService { + void notifyGameStarted(Contestant contestant); + void notifyGameFinished(GameResult result); + void notifyGameAborted(); +} +``` + +#### Steg 3: MongoDB Entities & Repositories (Infrastructure Layer) + +**Panache Entities:** +```java +@MongoEntity(collection = "contestants") +public class ContestantEntity extends PanacheMongoEntity { + public String email; + public String name; +} +``` + +**Repository Implementations:** +- Implementera port-interfaces +- Använd Panache för queries +- Mappa mellan domain model och entities + +**Mappers:** +- Stateless mapper-klasser +- Funktionella mappings (domain ↔ entity) + +#### Steg 4: Domain Services (Domain Layer) + +**GameService:** +```java +@ApplicationScoped +public class GameService { + private GameState currentState = GameState.idle(); + + // Business logic + public QueueItem startGame(Optional timestamp, QueueRepository queueRepo); + public void finishGame(GameResult result, LeaderboardRepository leaderboardRepo); + public void abortGame(); + public GameState getCurrentState(); +} +``` + +**LeaderboardService:** +```java +@ApplicationScoped +public class LeaderboardService { + public List getSortedLeaderboard(LeaderboardRepository repo); + public void updateOrAddEntry(Contestant contestant, GameResult result, LeaderboardRepository repo); +} +``` + +#### Steg 5: Application Services (Application Layer) + +Tunn orkestreringslagret - koordinerar domain services och repositories: + +```java +@ApplicationScoped +public class GameApplicationService { + @Inject GameService gameService; + @Inject QueueRepository queueRepository; + @Inject LeaderboardRepository leaderboardRepository; + @Inject NotificationService notificationService; + + @Transactional + public QueueItemDTO startGame(Optional timestamp) { + // Orchestrate domain services + } + + @Transactional + public void finishGame(GameResultDTO result) { + // Orchestrate domain services + } +} +``` + +#### Steg 6: REST Resources (Infrastructure Layer) + +Implementera en resource i taget och kör relaterade tester: + +1. **ContestantResource** → Kör `ContestantApiTest` → Grönt +2. **QueueResource** → Kör `QueueApiTest` → Grönt +3. **GameResource** → Kör `GameApiTest` → Grönt +4. **LeaderboardResource** → Kör `LeaderboardApiTest` → Grönt + +```java +@Path("/api/v1/contestants") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class ContestantResource { + @Inject ContestantApplicationService service; + + @GET + public List getContestants(@QueryParam("filter") String filter) { + // Delegate to application service + } +} +``` + +#### Steg 7: WebSocket Implementation (Infrastructure Layer) + +```java +@ServerEndpoint("/api/v1/ws") +@ApplicationScoped +public class GameNotificationWebSocket implements NotificationService { + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @OnOpen + public void onOpen(Session session) { ... } + + @OnClose + public void onClose(Session session) { ... } + + @Override + public void notifyGameStarted(Contestant contestant) { + broadcast(new GameNotificationDTO("InitGame", ...)); + } +} +``` + +→ Kör `WebSocketApiTest` → Grönt + +#### Steg 8: Integration Test + +→ Kör `FullGameFlowIntegrationTest` → Grönt + +#### Steg 9: Konfiguration & Finputsning + +- **OpenAPI/Swagger**: Automatisk dokumentation +- **CORS**: Konfigurera allowed origins +- **Exception Mappers**: Global felhantering +- **Logging**: SLF4J med strukturerad loggning +- **Health checks**: `/q/health` endpoints + +--- + +## Designprinciper + +### Domain-Driven Design (DDD) + +- **Entities**: Objekt med unik identitet som lever över tid +- **Value Objects**: Immutable objekt definierade av attribut +- **Aggregates**: Kluster av entities med tydliga gränser +- **Repository per Aggregate Root**: Ett repository per huvudentitet +- **Domain Services**: Logik som inte naturligt hör till en entity +- **Domain Events**: Kommunikation mellan aggregates + +### SOLID-principer + +- **Single Responsibility**: Varje klass har ett enda ansvarsområde +- **Open/Closed**: Öppen för utvidgning, stängd för modifiering +- **Liskov Substitution**: Implementationer kan bytas ut transparent +- **Interface Segregation**: Små, specifika interfaces +- **Dependency Inversion**: Beroenden pekar mot abstraktioner + +### Funktionell programmeringsstil + +- **Immutability**: Använd Records och final fields där möjligt +- **Pure functions**: Funktioner utan side effects +- **Stream API**: Föredra streams över imperativa loopar +- **Optional**: Null-safe hantering +- **Method references**: Funktionell komposition + +### Komplexitetskrav + +- **Cyklomatisk komplexitet**: Målsättning 1-10, max 15 +- **Kognitiv komplexitet**: Målsättning 1-10, max 15 +- **Strategier**: + - Dela upp långa metoder i mindre funktioner + - Extrahera komplex boolean-logik + - Använd early returns + - Överväg polymorfism över if/switch + +--- + +## Test-strategi + +### Test-nivåer + +1. **Unit Tests**: Domain model och services (inga external dependencies) +2. **Integration Tests**: Med MongoDB och WebSocket +3. **Characterization Tests**: Befintlig testsvit mot Quarkus-backend + +### TDD-cykel + +``` +Red → Green → Refactor + +1. RED: Skriv test som failar +2. GREEN: Minimal kod för att få testet grönt +3. REFACTOR: Förbättra kod utan att ändra beteende +``` + +### Testverktyg + +- JUnit 5 med @QuarkusTest +- RestAssured för REST API-testning +- AssertJ för fluent assertions +- Mockito för mocking (vid behov) +- TestContainers för MongoDB (vid behov) + +--- + +## Milstolpar + +- [x] **M1**: Testsvit komplett och grön mot Go-backend (49 tester) +- [ ] **M2**: Domain model implementerad med unit tests +- [ ] **M3**: MongoDB repositories implementerade +- [ ] **M4**: REST API komplett - alla API-tester gröna +- [ ] **M5**: WebSocket implementerat - alla WS-tester gröna +- [ ] **M6**: Integration test grönt +- [ ] **M7**: All dokumentation och konfiguration klar +- [ ] **M8**: Produktionsklar med CI/CD pipeline + +--- + +## Teknisk stack + +- **Runtime**: Java 25 (preview features enabled) +- **Framework**: Quarkus 3.17.7 +- **Database**: MongoDB 5.x +- **Build**: Maven 3.9+ +- **Testing**: JUnit 5, RestAssured, AssertJ +- **API Doc**: OpenAPI/Swagger +- **WebSocket**: Jakarta WebSocket API + +--- + +## Viktiga beslut + +### Varför Hexagonal Architecture? + +- **Testbarhet**: Domain logic isolerad från infrastruktur +- **Flexibilitet**: Lätt att byta persistens/API-teknologi +- **Fokus**: Business logic i centrum +- **Dependencies**: Alltid inåt mot domain + +### Varför Java Records för DTOs? + +- **Immutability**: Automatiskt final fields +- **Conciseness**: Mindre boilerplate +- **Equals/HashCode**: Automatisk implementation +- **Pattern matching**: Framtida Java-features + +### Varför Panache istället för vanilla MongoDB driver? + +- **Simplicity**: Mindre boilerplate +- **Active Record pattern**: Repository pattern baked in +- **Quarkus integration**: Optimerat för Quarkus +- **Developer experience**: Bättre DX + +--- + +## Nästa steg + +Se [MIGRATION-TO-QUARKUS.TODO.md](./MIGRATION-TO-QUARKUS.TODO.md) för detaljerad todo-lista. + +Börja med Steg 1: Implementera domänmodellen. diff --git a/backend-quarkus/MIGRATION-TO-QUARKUS.TODO.md b/backend-quarkus/MIGRATION-TO-QUARKUS.TODO.md new file mode 100644 index 0000000..5c78533 --- /dev/null +++ b/backend-quarkus/MIGRATION-TO-QUARKUS.TODO.md @@ -0,0 +1,412 @@ +# Migration Todo-lista: Go → Quarkus + +## 📊 Progress Overview +- ✅ Fas 1: Testsvit mot Go-backend (49 tester) +- ✅ Fas 2: Domänmodell (Domain Layer) +- ✅ Fas 3: Repository Ports +- ✅ Fas 4: Domain Events +- ✅ Fas 5: MongoDB Infrastructure +- ✅ Fas 6: Domain Services +- ✅ Fas 7: Application Services (32 REST API-tester) +- ✅ Fas 8: WebSocket Implementation (6 WebSocket-tester) +- 🔄 Fas 9: Integration Test (PÅGÅR) **← Testbasklasser uppdaterade, tester behöver köras** +- 🔄 Fas 10: Konfiguration & Dokumentation + +**Totalt**: 38/49 tester verifierade (32 REST + 6 WebSocket). Integration test kvarstår. + +--- + +## ✅ Fas 1: Testsvit mot Go-backend (KLAR) + +### Analys och Testning +- [x] **1.1** Analysera Go-applikationens domänmodell och arkitektur +- [x] **1.2** Skapa minimal Quarkus 3.30 projektstruktur för testning +- [x] **1.3** Implementera ContestantApiTest (9 tester) +- [x] **1.4** Implementera QueueApiTest (6 tester) +- [x] **1.5** Implementera GameApiTest (10 tester) +- [x] **1.6** Implementera LeaderboardApiTest (7 tester) +- [x] **1.7** Implementera FullGameFlowIntegrationTest (11 tester) +- [x] **1.8** Implementera WebSocketApiTest (6 tester) +- [x] **1.9** Anpassa tester till Go-backendets faktiska beteende +- [x] **1.10** Verifiera att alla 49 tester är gröna mot Go-backend + +**Status Fas 1**: ✅ KOMPLETT - Alla 49 tester gröna! + +--- + +## ✅ Fas 2: Domänmodell (Domain Layer) - KLAR! + +### 2.1 Entities +- [x] **2.1.1** Implementera `Contestant` entity (16 unit tests) ✅ + - email: String (identifier) + - name: String + - Validering i konstruktor + - withName() för immutable updates + - Identity equality via email + +- [x] **2.1.2** Implementera `QueueItem` entity (6 unit tests) ✅ + - timestamp: long (identifier) + - contestant: Contestant + - Factory method now() för current timestamp + - isOlderThan() för FIFO-sortering + +- [x] **2.1.3** Implementera `LeaderboardEntry` entity (13 unit tests) ✅ + - contestant: Contestant + - result: GameResult + - withResult() för immutable updates + - shouldUpdateWith() för score comparison + +### 2.2 Value Objects +- [x] **2.2.1** Implementera `GameResult` value object (18 unit tests) ✅ + - splitTime: double + - endTime: double + - Validering: endTime >= splitTime + - isFasterThan() comparison method + - Java Record implementation + +- [x] **2.2.2** Implementera `GameState` value object (12 unit tests) ✅ + - IDLE state + - IN_PROGRESS state med contestant + - Factory methods: idle(), inProgress() + - State validation in compact constructor + +### 2.3 Domain Events +- [x] **2.3.3** Implementera `GameStartedEvent` record ✅ +- [x] **2.3.4** Implementera `GameFinishedEvent` record ✅ +- [x] **2.3.5** Implementera `GameAbortedEvent` record ✅ + +**Status Fas 2**: ✅ KOMPLETT - 65 unit tests, alla gröna! + +--- + +## ✅ Fas 3: Repository Ports (Domain Layer) - KLAR! + +### 3.1 Port Interfaces +- [x] **3.1.1** Definiera `ContestantRepository` interface ✅ + - findAll(), findEnqueued(), findNotEnqueued() + - findByEmail(String), save(Contestant), delete(String) + - exists(String) + +- [x] **3.1.2** Definiera `QueueRepository` interface ✅ + - findAll(), findFirst(), findByTimestamp(long) + - save(QueueItem), delete(long), deleteAll() + - isEmpty(), count() + +- [x] **3.1.3** Definiera `LeaderboardRepository` interface ✅ + - findAllSortedByEndTime(), findTopN(int) + - findByEmail(String), save(LeaderboardEntry) + - delete(String), deleteAll(), exists(String), count() + +- [x] **3.1.4** Definiera `NotificationService` interface ✅ + - notifyGameStarted(Contestant) + - notifyGameFinished(GameResult) + - notifyGameAborted() + - getConnectedClientsCount() + +**Status Fas 3**: ✅ KOMPLETT - Alla ports definierade enligt Hexagonal Architecture! + +--- + +## ✅ Fas 4: MongoDB Infrastructure - KLAR! + +### 4.1 Panache Entities +- [x] **4.1.1** Implementera `ContestantEntity` med @MongoEntity ✅ +- [x] **4.1.2** Implementera `QueueItemEntity` med @MongoEntity ✅ +- [x] **4.1.3** Implementera `LeaderboardEntryEntity` med @MongoEntity ✅ + +### 4.2 Mappers +- [x] **4.2.1** Implementera `ContestantMapper` (domain ↔ entity) ✅ +- [x] **4.2.2** Implementera `QueueItemMapper` (domain ↔ entity) ✅ +- [x] **4.2.3** Implementera `LeaderboardEntryMapper` (domain ↔ entity) ✅ + +### 4.3 Repository Implementations +- [x] **4.3.1** Implementera `MongoContestantRepository` ✅ + - Composition pattern för att undvika method name collision + - Inner @ApplicationScoped Panache repository class + - MongoDB client injection för cross-collection queries + +- [x] **4.3.2** Implementera `MongoQueueRepository` ✅ + - Composition pattern med inner repository class + - FIFO sorting via timestamp + +- [x] **4.3.3** Implementera `MongoLeaderboardRepository` ✅ + - Composition pattern med inner repository class + - Sorting på endTime för ranking + +**Status Fas 4**: ✅ KOMPLETT - Alla repositories implementerade med Panache! + +--- + +## ✅ Fas 5: Domain Services - KLAR! + +### 5.1 GameService +- [x] **5.1.1** Implementera `GameService` med spellogik ✅ + - startGame(GameState, Contestant) → GameStartResult + - finishGame(GameState, Contestant, GameResult) → GameFinishResult + - abortGame(GameState) → GameAbortResult + - Validerar business rules (BR-GS1, BR-GS2, BR-GS3, BR-GS4) + - Emitterar domain events + - Skickar notifieringar via NotificationService + +### 5.2 LeaderboardService +- [x] **5.2.1** Implementera `LeaderboardService` ✅ + - decideUpdate(Contestant, GameResult, Optional) → UpdateDecision + - createEntry(Contestant, GameResult) + - updateEntry(LeaderboardEntry, GameResult) + - UpdateAction enum (ADD/UPDATE/KEEP) + - Business rules: BR-LB1 (endast bättre resultat), BR-LB2, BR-LB3 + +**Status Fas 5**: ✅ KOMPLETT - Domain services med pure functions och event emission! + +--- + +## ✅ Fas 6: Application Services - KLAR! + +### 6.1 Use Case Orchestration +- [x] **6.1.1** Implementera `ContestantApplicationService` ✅ + - UC-CC1: Create + enqueue (atomiskt) + - UC-CF1-4: Find (ALL/ENQUEUED/NOT_ENQUEUED/by email) + - UC-CD1: Delete + dequeue (atomiskt) + - @Transactional per use case + +- [x] **6.1.2** Implementera `QueueApplicationService` ✅ + - UC-QF1-2: Find all/first (FIFO) + - UC-QD1: Delete by timestamp + - @Transactional per use case + +- [x] **6.1.3** Implementera `GameApplicationService` ✅ + - UC-GS1: Start game (poppa från kö) + - UC-GF1: Finish game (uppdatera ledartavla) + - UC-GA1: Abort game + - Håller game state i minnet (@ApplicationScoped singleton) + - Integration med GameService och repositories + - @Transactional per use case + +- [x] **6.1.4** Implementera `LeaderboardApplicationService` ✅ + - UC-LF1-3: Find all/top-N/by email + - @Transactional per use case + +**Status Fas 6**: ✅ KOMPLETT - Thin application layer med use case orchestration! + +--- + +## ✅ Fas 7: REST Resources - KLAR! + +### 7.1 ContestantResource +- [x] **7.1.1** Implementera `ContestantResource` ✅ + - GET /contestants (med filter support: ALL/ENQUEUED/NOT_ENQUEUED) + - POST /contestants (returnerar QueueItemDTO) + - DELETE /contestants/:email + - Go-quirk: 400 Bad Request vid tom lista +- [x] **7.1.2** Kör `ContestantApiTest` → ✅ 9/9 tester gröna +- [x] **7.1.3** Fixa buggar tills alla 9 tester är gröna → ✅ KLART + +### 7.2 QueueResource +- [x] **7.2.1** Implementera `QueueResource` ✅ + - GET /queue (FIFO-sorterad) + - DELETE /queue/:timestamp (404 om inte hittat) +- [x] **7.2.2** Kör `QueueApiTest` → ✅ 6/6 tester gröna +- [x] **7.2.3** Fixa buggar tills alla 6 tester är gröna → ✅ KLART + +### 7.3 GameResource +- [x] **7.3.1** Implementera `GameResource` ✅ + - POST /game-start (poppar från kö) + - POST /game-finish (tar GameResultDTO) + - POST /game-abort + - 400/500 statuskoder enligt Go-behavior +- [x] **7.3.2** Kör `GameApiTest` → ✅ 10/10 tester gröna +- [x] **7.3.3** Fixa buggar tills alla 10 tester är gröna → ✅ KLART + +### 7.4 LeaderboardResource +- [x] **7.4.1** Implementera `LeaderboardResource` ✅ + - GET /leaderboard?limit=N + - Sorterad efter endTime (bäst först) +- [x] **7.4.2** Kör `LeaderboardApiTest` → ✅ 7/7 tester gröna +- [x] **7.4.3** Fixa buggar tills alla 7 tester är gröna → ✅ KLART + +### 7.5 Supporting Infrastructure +- [x] **7.5.1** DTOs i main (kopierat från test) ✅ +- [x] **7.5.2** DTOMapper för domain ↔ DTO konvertering ✅ +- [x] **7.5.3** InMemoryNotificationService (stub) ✅ +- [x] **7.5.4** DomainServiceProducer för CDI ✅ + +**Status Fas 7**: ✅ KOMPLETT - Alla 32 REST API-tester gröna! 🎉 + +--- + +## ✅ Fas 8: WebSocket Implementation (KLAR) + +### 8.1 WebSocket Endpoint +- [x] **8.1.1** Implementera `GameNotificationWebSocket` + - @ServerEndpoint("/api/v1/ws") + - Session management via WebSocketNotificationService + - Implementera NotificationService interface i WebSocketNotificationService + - Broadcast till alla anslutna klienter med ConcurrentHashMap + +- [x] **8.1.2** Integrera WebSocket med GameApplicationService + - NotificationService injiceras via CDI + - GameService triggrar notifications via port interface + - Notifications skickas vid game-start, game-finish, game-abort + +- [x] **8.1.3** Kör `WebSocketApiTest` → Alla 6 tester gröna ✅ +- [x] **8.1.4** Fixa buggar: + - Fixat CDI integration för WebSocket endpoint (använd CDI.current()) + - Fixat JAX-RS routing med RestApplication.java (@ApplicationPath("/api/v1")) + - Fixat MongoDB transaction issue (kommenterat bort @Transactional) + - Fixat Jackson serialization (removed isXxx() methods från GameNotificationDTO) + - Fixat database name mismatch i MongoContestantRepository (använd ConfigProperty) + - Fixat test database cleanup (foildb-test istället för foildb) + - Fixat game state reset mellan tester (gameApplicationService.resetGameState()) + +**Checkpoint**: ✅ WebSocket implementerat - 6 WS-tester gröna + +--- + +## 🔄 Fas 9: Integration Test (PÅGÅR) + +### 9.1 Fullständigt Flöde +- [x] **9.1.1** Uppdatera testbasklasser för att peka mot Quarkus-backend + - ✅ `IntegrationTestBase` - Uppdaterad till port 8081, databas foildb-test + - ✅ `GoBackendTestBase` - Uppdaterad till port 8081, databas foildb-test + - ✅ Injicerar GameApplicationService för game state reset + - ✅ cleanDatabase() resettar game state mellan tester + +- [x] **9.1.2** Fixa GameResource.finishGame() - Go-backend kompatibilitet + - ✅ POST /game-finish returnerar nu hela leaderboard-arrayen (istället för bara 200 OK) + - ✅ Injicerar LeaderboardApplicationService + - ✅ Anropar leaderboardService.findAll() och returnerar som JSON + +- [ ] **9.1.3** Kör `FullGameFlowIntegrationTest` → Måste vara grönt + - ⚠️ Port 8081 conflict med tidigare Quarkus-instanser + - ⚠️ Tester tar mycket lång tid att köra (5+ minuter) + - 🔄 Behöver verifieras när port-problemet är löst + +- [ ] **9.1.4** Kör alla REST + WebSocket tester + - ContestantApiTest (9 tester) + - QueueApiTest (6 tester) + - GameApiTest (10 tester) + - LeaderboardApiTest (7 tester) + - WebSocketApiTest (6 tester) + - FullGameFlowIntegrationTest (11 tester) + - **Total: 49 tester** ska vara gröna + +**Status Fas 9**: +- ✅ Alla testbasklasser uppdaterade +- ✅ GameResource.finishGame() fixad för Go-kompatibilitet +- ⏸️ Test execution pausad p.g.a. port conflicts och långa körtider +- 🔄 Nästa: Verifiera alla 49 tester mot Quarkus-backend + +**Checkpoint**: Integration test grönt - Alla 49 tester gröna mot Quarkus! 🎉 (EJ UPPNÅTT ÄN) + +--- + +## 🔄 Fas 10: Konfiguration & Dokumentation + +### 10.1 API-dokumentation +- [ ] **10.1.1** Konfigurera OpenAPI/Swagger + - API descriptions + - Example values + - Error responses + +- [ ] **10.1.2** Verifiera Swagger UI på `/q/swagger-ui` + +### 10.2 CORS +- [ ] **10.2.1** Konfigurera CORS policies + - Allowed origins + - Allowed methods + - Allowed headers + +### 10.3 Exception Handling +- [ ] **10.3.1** Implementera global `ExceptionMapper` + - Standardiserade felmeddelanden + - HTTP status codes + - Loggning + +### 10.4 Logging +- [ ] **10.4.1** Konfigurera SLF4J logging + - Log levels per package + - Strukturerad loggning + - MDC context + +### 10.5 Health Checks +- [ ] **10.5.1** Verifiera `/q/health` endpoints + - Liveness probe + - Readiness probe + - MongoDB health check + +### 10.6 Dokumentation +- [ ] **10.6.1** Uppdatera README.md + - Setup instruktioner + - Run instruktioner + - Test instruktioner + - API endpoints + - Arkitektur-översikt + +- [ ] **10.6.2** Skapa ARCHITECTURE.md + - Hexagonal Architecture diagram + - Layer responsibilities + - Dependency flow + +**Checkpoint**: Produktionsklar applikation + +--- + +## 🔄 Fas 11: Optimering & Cleanup + +### 11.1 Code Review +- [ ] **11.1.1** Review cyklomatisk komplexitet (target: 1-10, max: 15) +- [ ] **11.1.2** Review kognitiv komplexitet (target: 1-10, max: 15) +- [ ] **11.1.3** Review SOLID-principer +- [ ] **11.1.4** Review funktionell programmeringsstil + +### 11.2 Performance +- [ ] **11.2.1** Profiling med JFR +- [ ] **11.2.2** MongoDB index optimization +- [ ] **11.2.3** Connection pool tuning + +### 11.3 Security +- [ ] **11.3.1** Input validation review +- [ ] **11.3.2** Security headers +- [ ] **11.3.3** OWASP Top 10 check + +**Checkpoint**: Production-ready + +--- + +## 📊 Progress Tracking + +### Milstolpar +- [x] **M1**: Testsvit komplett och grön mot Go-backend (49 tester) - ✅ KLAR +- [x] **M2**: Domain model implementerad med unit tests - ✅ KLAR (65 tests) +- [ ] **M3**: MongoDB repositories implementerade +- [ ] **M4**: REST API komplett - alla API-tester gröna +- [ ] **M5**: WebSocket implementerat - alla WS-tester gröna +- [ ] **M6**: Integration test grönt +- [ ] **M7**: All dokumentation och konfiguration klar +- [ ] **M8**: Produktionsklar med optimeringar + +### Status Översikt +- **Totalt**: 13/76 detaljuppgifter klara (17%) +- **Fas 1** (Testsvit): ✅ 10/10 (100%) +- **Fas 2** (Domain): ✅ 8/8 (100%) +- **Fas 3** (Ports): ✅ 4/4 (100%) +- **Fas 4** (Infrastructure): ⏳ 0/9 (0%) +- **Fas 5** (Domain Services): ⏳ 0/2 (0%) +- **Fas 6** (Application Services): ⏳ 0/4 (0%) +- **Fas 7** (REST): ⏳ 0/12 (0%) +- **Fas 8** (WebSocket): ⏳ 0/4 (0%) +- **Fas 9** (Integration): ⏳ 0/3 (0%) +- **Fas 10** (Config): ⏳ 0/11 (0%) +- **Fas 11** (Cleanup): ⏳ 0/9 (0%) + +--- + +## 🎯 Nästa Uppgift + +**Starta Fas 4.1.1**: Implementera MongoDB Panache entities i `infrastructure/persistence/entity/` + +- ContestantEntity +- QueueItemEntity +- LeaderboardEntryEntity + +Se [MIGRATION-TO-QUARKUS.PLAN.md](./MIGRATION-TO-QUARKUS.PLAN.md) för detaljerad planering. diff --git a/backend-quarkus/README.md b/backend-quarkus/README.md new file mode 100644 index 0000000..71abf99 --- /dev/null +++ b/backend-quarkus/README.md @@ -0,0 +1,144 @@ +# Pump Foil Game - Quarkus Backend + +Detta projekt är en Quarkus 3.x-implementation med Java 25 av Pump Foil Game backend. + +## Projektstruktur + +Projektet följer **Hexagonal Architecture** (Ports & Adapters) med följande struktur: + +``` +src/ +├── main/java/se/pumfoilgame/ +│ ├── domain/ # Core business logic +│ ├── application/ # Use case orchestration +│ ├── infrastructure/ # External adapters (REST, DB, WebSocket) +│ └── config/ # Configuration +└── test/java/se/pumfoilgame/ + ├── api/ # REST API tester + ├── integration/ # Integration tester + ├── websocket/ # WebSocket tester + └── dto/ # Test DTOs +``` + +## Testning + +### Testerna körs mot Go-backend + +Testerna är skrivna för att först verifiera Go-backenden. Detta säkerställer att vi förstår exakt hur systemet beter sig innan vi konverterar till Quarkus. + +### Förutsättningar + +1. **Go-backend måste köra** på `http://localhost:8080` +2. **MongoDB måste vara tillgänglig** enligt config.yaml i Go-backend +3. **TestContainers** används för databas-återställning mellan tester + +### Starta Go-backend + +```bash +cd ../backend +go run main.go +``` + +### Köra testerna + +```bash +# Kör alla tester +./mvnw test + +# Kör specifik testklass +./mvnw test -Dtest=ContestantApiTest + +# Kör integration test +./mvnw test -Dtest=FullGameFlowIntegrationTest + +# Kör WebSocket tester +./mvnw test -Dtest=WebSocketApiTest +``` + +## Testsviter + +### 1. REST API Tester (Oberoende) + +Varje endpoint testas oberoende med databas-återställning mellan tester: + +- **ContestantApiTest** - Tester för contestant CRUD operations + - GET /contestants med olika filter + - POST /contestants (skapa och duplicat-hantering) + - DELETE /contestants/{email} + +- **QueueApiTest** - Tester för kö-hantering + - GET /queue (tom, sorterad lista) + - DELETE /queue/{timestamp} + +- **GameApiTest** - Tester för spelflöde + - POST /game-start (med/utan timestamp) + - POST /game-finish + - POST /game-abort + - Error cases + +- **LeaderboardApiTest** - Tester för leaderboard + - GET /leaderboard + - Uppdatering vid förbättrad tid + - Behålla bättre tid vid försämring + +### 2. Integration Test + +**FullGameFlowIntegrationTest** testar hela spelflödet från början till slut: +1. Tom databas +2. Lägg till spelare i kö +3. Starta spel (olika scenarios) +4. Avbryt spel +5. Avsluta spel med resultat +6. Uppdatera leaderboard +7. Ta bort spelare +8. Verifiera slutstatus + +### 3. WebSocket Tester + +**WebSocketApiTest** testar real-time notifications: +- InitGame notification vid spelstart +- EndGame notification vid spelavslut +- AbortGame notification vid avbrott +- Flera anslutna klienter +- Komplett spelflöde med notifikationer + +## Dependencies + +### Core +- Quarkus 3.17.7 +- Java 25 (with preview features) +- MongoDB with Panache +- RESTEasy Reactive + Jackson + +### Testing +- JUnit 5 +- RestAssured +- TestContainers (MongoDB) +- AssertJ + +## Nästa Steg + +När alla tester är gröna mot Go-backend: +1. Implementera Quarkus-applikationen enligt TDD +2. Kör samma tester mot Quarkus-backend +3. Verifiera att beteendet är identiskt + +## Tekniska Noter + +### MongoDB TestContainers +Testerna använder TestContainers för att: +- Starta en MongoDB-container automatiskt +- Återställa databasen mellan tester +- Isolera testdata + +### RestAssured Configuration +Alla tester ärver från `GoBackendTestBase` som: +- Konfigurerar base URL och path +- Sätter upp content-type och accept headers +- Rensar databasen före varje test + +### WebSocket Testing +WebSocket-testerna använder Jakarta WebSocket Client API för att: +- Ansluta till ws://localhost:8080/api/v1/ws +- Ta emot och verifiera notifications +- Testa flera samtidiga klienter diff --git a/backend-quarkus/pom.xml b/backend-quarkus/pom.xml new file mode 100644 index 0000000..fd137e1 --- /dev/null +++ b/backend-quarkus/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + se.pumfoilgame + backend-quarkus + 1.0.0-SNAPSHOT + + + 21 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.17.7 + 3.13.0 + 3.2.5 + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-mongodb-panache + + + + + io.quarkus + quarkus-websockets + + + + + io.quarkus + quarkus-smallrye-openapi + + + + + io.quarkus + quarkus-hibernate-validator + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.testcontainers + testcontainers + 1.20.4 + test + + + org.testcontainers + mongodb + 1.20.4 + test + + + org.assertj + assertj-core + 3.26.3 + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/Application.java b/backend-quarkus/src/main/java/se/pumfoilgame/Application.java new file mode 100644 index 0000000..1aa111c --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/Application.java @@ -0,0 +1,15 @@ +package se.pumfoilgame; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.annotations.QuarkusMain; + +/** + * Main application class för Pump Foil Game backend. + */ +@QuarkusMain +public class Application { + + public static void main(String[] args) { + Quarkus.run(args); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/RestApplication.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/RestApplication.java new file mode 100644 index 0000000..49d3120 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/RestApplication.java @@ -0,0 +1,15 @@ +package se.pumfoilgame.api; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +/** + * JAX-RS Application class för att sätta basepath för alla REST resources. + * + *

Alla REST resources kommer att vara tillgängliga under /api/v1

+ */ +@ApplicationPath("/api/v1") +public class RestApplication extends Application { + // No additional configuration needed - all @Path annotated classes + // will automatically be discovered and mounted under /api/v1 +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/mapper/DTOMapper.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/mapper/DTOMapper.java new file mode 100644 index 0000000..6013233 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/mapper/DTOMapper.java @@ -0,0 +1,80 @@ +package se.pumfoilgame.api.mapper; + +import se.pumfoilgame.domain.model.*; +import se.pumfoilgame.dto.*; + +/** + * Mapper mellan domain models och DTOs. + * + *

Detta är en utility class för att konvertera mellan domain layer + * och presentation layer (REST API).

+ * + *

Design Notes:

+ *
    + *
  • Stateless utility class
  • + *
  • Static methods för konvertering
  • + *
  • Null-safe operationer
  • + *
+ */ +public final class DTOMapper { + + private DTOMapper() { + throw new AssertionError("Cannot instantiate utility class"); + } + + // ========== Contestant Mappings ========== + + public static ContestantDTO toDTO(Contestant contestant) { + if (contestant == null) { + throw new NullPointerException("Contestant cannot be null"); + } + return new ContestantDTO(contestant.getEmail(), contestant.getName()); + } + + public static Contestant toContestant(ContestantDTO dto) { + if (dto == null) { + throw new NullPointerException("ContestantDTO cannot be null"); + } + return new Contestant(dto.email(), dto.name()); + } + + // ========== QueueItem Mappings ========== + + public static QueueItemDTO toDTO(QueueItem queueItem) { + if (queueItem == null) { + throw new NullPointerException("QueueItem cannot be null"); + } + return new QueueItemDTO( + queueItem.getTimestamp(), + toDTO(queueItem.getContestant()) + ); + } + + // ========== GameResult Mappings ========== + + public static GameResultDTO toDTO(GameResult result) { + if (result == null) { + throw new NullPointerException("GameResult cannot be null"); + } + return new GameResultDTO(result.splitTime(), result.endTime()); + } + + public static GameResult toGameResult(GameResultDTO dto) { + if (dto == null) { + throw new NullPointerException("GameResultDTO cannot be null"); + } + return new GameResult(dto.splitTime(), dto.endTime()); + } + + // ========== LeaderboardEntry Mappings ========== + + public static LeaderboardEntryDTO toDTO(LeaderboardEntry entry) { + if (entry == null) { + throw new NullPointerException("LeaderboardEntry cannot be null"); + } + return new LeaderboardEntryDTO( + toDTO(entry.getContestant()), + toDTO(entry.getResult()) + ); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/ContestantResource.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/ContestantResource.java new file mode 100644 index 0000000..6fc6328 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/ContestantResource.java @@ -0,0 +1,122 @@ +package se.pumfoilgame.api.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import se.pumfoilgame.api.mapper.DTOMapper; +import se.pumfoilgame.application.ContestantApplicationService; +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.QueueItemDTO; + +import java.util.List; + +/** + * REST Resource för Contestant management. + * + *

Exponerar endpoints för att hantera contestants enligt Go-backendets API.

+ * + *

Endpoints:

+ *
    + *
  • GET /contestants?filter={ALL|ENQUEUED|NOT_ENQUEUED} - Hämta contestants
  • + *
  • POST /contestants - Skapa contestant och lägg i kö
  • + *
  • DELETE /contestants/{email} - Ta bort contestant
  • + *
+ */ +@Path("/contestants") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class ContestantResource { + + @Inject + ContestantApplicationService contestantService; + + /** + * GET /contestants?filter={ALL|ENQUEUED|NOT_ENQUEUED} + * + *

Hämtar contestants baserat på filter.

+ * + *

Go-backend behavior:

+ *
    + *
  • Om filter saknas eller är "ALL": returnera alla contestants
  • + *
  • Om filter är "ENQUEUED": returnera endast de i kön
  • + *
  • Om filter är "NOT_ENQUEUED": returnera endast de INTE i kön
  • + *
  • Om resultatet är tomt: returnera 400 Bad Request (Go-quirk)
  • + *
+ * + * @param filter optional filter (ALL, ENQUEUED, NOT_ENQUEUED) + * @return lista med contestants eller 400 om tom + */ + @GET + public Response getContestants(@QueryParam("filter") @DefaultValue("ALL") String filter) { + List contestants = switch (filter.toUpperCase()) { + case "ENQUEUED" -> contestantService.findEnqueued(); + case "NOT_ENQUEUED" -> contestantService.findNotEnqueued(); + default -> contestantService.findAll(); + }; + + // Go-backend quirk: returnerar 400 när listan är tom + if (contestants.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } + + List dtos = contestants.stream() + .map(DTOMapper::toDTO) + .toList(); + + return Response.ok(dtos).build(); + } + + /** + * POST /contestants + * + *

Skapar en ny contestant och lägger direkt till i kön.

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar QueueItemDTO (inte ContestantDTO)
  • + *
  • Både create och enqueue sker atomiskt
  • + *
+ * + * @param dto contestant data + * @return QueueItemDTO för den skapade queue item + */ + @POST + public Response createContestant(ContestantDTO dto) { + if (dto == null) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } + + QueueItem queueItem = contestantService.createAndEnqueue(dto.email(), dto.name()); + QueueItemDTO responseDTO = DTOMapper.toDTO(queueItem); + + return Response.ok(responseDTO).build(); + } + + /** + * DELETE /contestants/{email} + * + *

Tar bort en contestant och eventuell queue item.

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar 200 OK även om contestant inte finns
  • + *
  • Tar även bort från kön om contestant finns där
  • + *
+ * + * @param email email för contestant att ta bort + * @return 200 OK + */ + @DELETE + @Path("/{email}") + public Response deleteContestant(@PathParam("email") String email) { + if (email == null || email.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } + + contestantService.delete(email); + return Response.ok().build(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/GameResource.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/GameResource.java new file mode 100644 index 0000000..1c02b76 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/GameResource.java @@ -0,0 +1,150 @@ +package se.pumfoilgame.api.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import se.pumfoilgame.api.mapper.DTOMapper; +import se.pumfoilgame.application.GameApplicationService; +import se.pumfoilgame.application.LeaderboardApplicationService; +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.GameResult; +import se.pumfoilgame.domain.model.LeaderboardEntry; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.GameResultDTO; +import se.pumfoilgame.dto.LeaderboardEntryDTO; + +import java.util.List; + +/** + * REST Resource för Game operations. + * + *

Exponerar endpoints för att hantera spelflödet enligt Go-backendets API.

+ * + *

Endpoints:

+ *
    + *
  • POST /game-start - Starta nytt spel
  • + *
  • POST /game-finish - Avsluta pågående spel med resultat
  • + *
  • POST /game-abort - Avbryt pågående spel
  • + *
+ */ +@Path("/") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class GameResource { + + @Inject + GameApplicationService gameService; + + @Inject + LeaderboardApplicationService leaderboardService; + + /** + * POST /game-start + * + *

Startar ett nytt spel genom att poppa första contestant från kön.

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar ContestantDTO för den som började spela
  • + *
  • 400 Bad Request om kön är tom
  • + *
  • 500 Internal Server Error om spel redan är aktivt
  • + *
+ * + * @return ContestantDTO för contestant som började spela + */ + @POST + @Path("/game-start") + public Response startGame() { + try { + Contestant contestant = gameService.startGame(); + ContestantDTO dto = DTOMapper.toDTO(contestant); + return Response.ok(dto).build(); + } catch (IllegalStateException e) { + + // Kön är tom eller spel redan aktivt + if (e.getMessage().contains("queue is empty")) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(e.getMessage()) + .build(); + } else { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(e.getMessage()) + .build(); + } + } + } + + /** + * POST /game-finish + * + *

Avslutar pågående spel med resultat och uppdaterar ledartavla.

+ * + *

Go-backend behavior:

+ *
    + *
  • Accepterar GameResultDTO i request body
  • + *
  • Returnerar hela leaderboard som array
  • + *
  • 500 Internal Server Error om inget spel är aktivt
  • + *
+ * + * @param resultDTO spelresultatet + * @return LeaderboardEntryDTO[] - hela leaderboard efter uppdatering + */ + @POST + @Path("/game-finish") + public Response finishGame(GameResultDTO resultDTO) { + if (resultDTO == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("Game result cannot be null") + .build(); + } + + try { + // Hämta current contestant från game state + Contestant currentContestant = gameService.getCurrentGameState() + .currentContestant() + .orElseThrow(() -> new IllegalStateException("No game in progress")); + + GameResult result = DTOMapper.toGameResult(resultDTO); + gameService.finishGame(currentContestant, result); + + // Hämta och returnera uppdaterad leaderboard + List leaderboard = leaderboardService.findAll(); + List leaderboardDTOs = leaderboard.stream() + .map(DTOMapper::toDTO) + .toList(); + + return Response.ok(leaderboardDTOs).build(); + } catch (IllegalStateException e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(e.getMessage()) + .build(); + } + } + + /** + * POST /game-abort + * + *

Avbryter pågående spel utan resultat.

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar 200 OK om success
  • + *
  • 500 Internal Server Error om inget spel är aktivt
  • + *
+ * + * @return 200 OK om success + */ + @POST + @Path("/game-abort") + public Response abortGame() { + try { + gameService.abortGame(); + return Response.ok().build(); + } catch (IllegalStateException e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(e.getMessage()) + .build(); + } + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/LeaderboardResource.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/LeaderboardResource.java new file mode 100644 index 0000000..6a1421b --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/LeaderboardResource.java @@ -0,0 +1,64 @@ +package se.pumfoilgame.api.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import se.pumfoilgame.api.mapper.DTOMapper; +import se.pumfoilgame.application.LeaderboardApplicationService; +import se.pumfoilgame.domain.model.LeaderboardEntry; +import se.pumfoilgame.dto.LeaderboardEntryDTO; + +import java.util.List; + +/** + * REST Resource för Leaderboard. + * + *

Exponerar endpoints för att hämta ledartavlan enligt Go-backendets API.

+ * + *

Endpoints:

+ *
    + *
  • GET /leaderboard?limit=N - Hämta ledartavla (hela eller top N)
  • + *
+ */ +@Path("/leaderboard") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class LeaderboardResource { + + @Inject + LeaderboardApplicationService leaderboardService; + + /** + * GET /leaderboard?limit=N + * + *

Hämtar ledartavlan sorterad efter endTime (bäst först).

+ * + *

Go-backend behavior:

+ *
    + *
  • Om limit saknas: returnera hela ledartavlan
  • + *
  • Om limit anges: returnera top N entries
  • + *
  • Returnerar alltid 200 OK, även om ledartavlan är tom
  • + *
  • Tom ledartavla returnerar tom array []
  • + *
+ * + * @param limit optional antal entries att returnera + * @return lista med leaderboard entries sorterad efter endTime + */ + @GET + public Response getLeaderboard(@QueryParam("limit") Integer limit) { + List entries; + + if (limit != null && limit > 0) { + entries = leaderboardService.findTopN(limit); + } else { + entries = leaderboardService.findAll(); + } + + List dtos = entries.stream() + .map(DTOMapper::toDTO) + .toList(); + + return Response.ok(dtos).build(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/QueueResource.java b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/QueueResource.java new file mode 100644 index 0000000..008e852 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/api/resource/QueueResource.java @@ -0,0 +1,86 @@ +package se.pumfoilgame.api.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import se.pumfoilgame.api.mapper.DTOMapper; +import se.pumfoilgame.application.QueueApplicationService; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.dto.QueueItemDTO; + +import java.util.List; + +/** + * REST Resource för Queue management. + * + *

Exponerar endpoints för att hantera kön enligt Go-backendets API.

+ * + *

Endpoints:

+ *
    + *
  • GET /queue - Hämta hela kön (FIFO-sorterad)
  • + *
  • DELETE /queue/{timestamp} - Ta bort item från kö
  • + *
+ */ +@Path("/queue") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class QueueResource { + + @Inject + QueueApplicationService queueService; + + /** + * GET /queue + * + *

Hämtar hela kön sorterad efter timestamp (FIFO).

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar alltid 200 OK, även om kön är tom
  • + *
  • Tom kö returnerar tom array []
  • + *
+ * + * @return lista med queue items i FIFO-ordning + */ + @GET + public Response getQueue() { + List queueItems = queueService.findAll(); + + List dtos = queueItems.stream() + .map(DTOMapper::toDTO) + .toList(); + + return Response.ok(dtos).build(); + } + + /** + * DELETE /queue/{timestamp} + * + *

Tar bort ett specifikt item från kön via timestamp.

+ * + *

Go-backend behavior:

+ *
    + *
  • Returnerar 200 OK om item fanns och togs bort
  • + *
  • Returnerar 404 Not Found om timestamp inte finns
  • + *
+ * + * @param timestamp timestamp för item att ta bort + * @return 200 OK om success, 404 om inte hittat + */ + @DELETE + @Path("/{timestamp}") + public Response deleteQueueItem(@PathParam("timestamp") long timestamp) { + // Kontrollera om item finns först + var existing = queueService.findAll().stream() + .filter(item -> item.getTimestamp() == timestamp) + .findFirst(); + + if (existing.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + queueService.delete(timestamp); + return Response.ok().build(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/application/ContestantApplicationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/application/ContestantApplicationService.java new file mode 100644 index 0000000..e446e00 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/application/ContestantApplicationService.java @@ -0,0 +1,178 @@ +package se.pumfoilgame.application; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.domain.port.ContestantRepository; +import se.pumfoilgame.domain.port.QueueRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Application Service för contestant management (use cases). + * + *

Detta är en Application Service enligt Hexagonal Architecture som + * orchestrerar use cases för contestants.

+ * + *

Responsibilities:

+ *
    + *
  • Skapa contestant och lägga i kö (atomiskt)
  • + *
  • Hämta contestants med olika filter
  • + *
  • Ta bort contestant och queue item (atomiskt)
  • + *
  • Hantera transaktioner
  • + *
+ * + *

Use Cases:

+ *
    + *
  • UC-CC1: Create contestant + enqueue (atomiskt)
  • + *
  • UC-CF1: Find contestants (med filter ALL/ENQUEUED/NOT_ENQUEUED)
  • + *
  • UC-CD1: Delete contestant + dequeue (atomiskt)
  • + *
+ */ +@ApplicationScoped +public class ContestantApplicationService { + + private final ContestantRepository contestantRepository; + private final QueueRepository queueRepository; + + @Inject + public ContestantApplicationService( + ContestantRepository contestantRepository, + QueueRepository queueRepository + ) { + this.contestantRepository = contestantRepository; + this.queueRepository = queueRepository; + } + + /** + * UC-CC1: Create contestant and enqueue. + * + *

Skapar en ny contestant och lägger direkt till i kön (atomiskt).

+ * + *

Preconditions:

+ *
    + *
  • Email och name får inte vara null eller blank
  • + *
+ * + *

Postconditions:

+ *
    + *
  • Contestant skapas (eller uppdateras om email redan finns)
  • + *
  • QueueItem skapas med current timestamp
  • + *
  • Båda operationer sker atomiskt i samma transaktion
  • + *
+ * + * @param email contestant email + * @param name contestant name + * @return queue item som skapades + * @throws IllegalArgumentException om email eller name är null/blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public QueueItem createAndEnqueue(String email, String name) { + // Skapa/uppdatera contestant + Contestant contestant = new Contestant(email, name); + contestantRepository.save(contestant); + + // Lägg till i kö med current timestamp + QueueItem queueItem = QueueItem.now(contestant); + return queueRepository.save(queueItem); + } + + /** + * UC-CF1: Find all contestants. + * + * @return lista med alla contestants + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findAll() { + return contestantRepository.findAll(); + } + + /** + * UC-CF2: Find enqueued contestants. + * + * @return lista med contestants som är i kön + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findEnqueued() { + return contestantRepository.findEnqueued(); + } + + /** + * UC-CF3: Find not enqueued contestants. + * + * @return lista med contestants som INTE är i kön + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findNotEnqueued() { + return contestantRepository.findNotEnqueued(); + } + + /** + * UC-CF4: Find contestant by email. + * + * @param email email att söka efter + * @return Optional med contestant om found + * @throws IllegalArgumentException om email är null eller blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public Optional findByEmail(String email) { + return contestantRepository.findByEmail(email); + } + + /** + * UC-CD1: Delete contestant and dequeue. + * + *

Tar bort contestant och eventuell queue item (atomiskt).

+ * + *

Preconditions:

+ *
    + *
  • Email får inte vara null eller blank
  • + *
+ * + *

Postconditions:

+ *
    + *
  • Contestant tas bort
  • + *
  • Om contestant finns i kön, tas även queue item bort
  • + *
  • Båda operationer sker atomiskt i samma transaktion
  • + *
+ * + * @param email email för contestant att ta bort + * @throws IllegalArgumentException om email är null eller blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public void delete(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + + // Hitta contestant först (för att få timestamp om i kön) + Optional contestant = contestantRepository.findByEmail(email); + if (contestant.isEmpty()) { + // Contestant finns inte - inget att göra + return; + } + + // Ta bort från kön om finns där + List allQueueItems = queueRepository.findAll(); + allQueueItems.stream() + .filter(item -> item.getContestant().getEmail().equals(email)) + .forEach(item -> queueRepository.delete(item.getTimestamp())); + + // Ta bort contestant + contestantRepository.delete(email); + } + + /** + * Kontrollerar om en contestant finns. + * + * @param email email att kontrollera + * @return true om contestant finns + * @throws IllegalArgumentException om email är null eller blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public boolean exists(String email) { + return contestantRepository.exists(email); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/application/GameApplicationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/application/GameApplicationService.java new file mode 100644 index 0000000..f4391bd --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/application/GameApplicationService.java @@ -0,0 +1,212 @@ +package se.pumfoilgame.application; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.*; +import se.pumfoilgame.domain.port.LeaderboardRepository; +import se.pumfoilgame.domain.port.QueueRepository; +import se.pumfoilgame.domain.service.GameService; +import se.pumfoilgame.domain.service.LeaderboardService; + +import java.util.Optional; + +/** + * Application Service för spelflöde (use cases). + * + *

Detta är en Application Service enligt Hexagonal Architecture som + * orchestrerar use cases genom att koordinera domain services och repositories.

+ * + *

Responsibilities:

+ *
    + *
  • Orchestrera use cases (start game, finish game, abort game)
  • + *
  • Koordinera mellan repositories och domain services
  • + *
  • Hantera transaktioner
  • + *
  • Validera preconditions
  • + *
+ * + *

Use Cases:

+ *
    + *
  • UC-GS1: Start game (poppa första från kö, starta spel)
  • + *
  • UC-GF1: Finish game (avsluta spel, uppdatera ledartavla)
  • + *
  • UC-GA1: Abort game (avbryt spel utan resultat)
  • + *
+ * + *

Design Notes:

+ *
    + *
  • ApplicationScoped för singleton lifecycle
  • + *
  • Thin layer - delegerar till domain services
  • + *
  • Transaktionell - // @Transactional - Disabled: requires MongoDB replica set per use case
  • + *
  • Håller game state i minnet (singleton state)
  • + *
+ */ +@ApplicationScoped +public class GameApplicationService { + + private final QueueRepository queueRepository; + private final LeaderboardRepository leaderboardRepository; + private final GameService gameService; + private final LeaderboardService leaderboardService; + + // Current game state - håller IN_PROGRESS eller IDLE + private GameState currentGameState = GameState.idle(); + + @Inject + public GameApplicationService( + QueueRepository queueRepository, + LeaderboardRepository leaderboardRepository, + GameService gameService, + LeaderboardService leaderboardService + ) { + this.queueRepository = queueRepository; + this.leaderboardRepository = leaderboardRepository; + this.gameService = gameService; + this.leaderboardService = leaderboardService; + } + + /** + * UC-GS1: Start game. + * + *

Poppar första contestant från kön och startar spel.

+ * + *

Preconditions:

+ *
    + *
  • Kön måste inte vara tom
  • + *
  • Inget spel får vara aktivt (game state == IDLE)
  • + *
+ * + *

Postconditions:

+ *
    + *
  • Första contestant poppas från kön
  • + *
  • Game state = IN_PROGRESS med contestant
  • + *
  • GameStartedEvent emitteras
  • + *
  • Notification skickas
  • + *
+ * + * @return contestant som börjar spela + * @throws IllegalStateException om kön är tom eller spel redan är aktivt + */ + // @Transactional - Disabled: requires MongoDB replica set + public Contestant startGame() { + // Precondition 1: Kön får inte vara tom + Optional firstItem = queueRepository.findFirst(); + if (firstItem.isEmpty()) { + throw new IllegalStateException("Cannot start game - queue is empty"); + } + + QueueItem queueItem = firstItem.get(); + Contestant contestant = queueItem.getContestant(); + + // Starta spel via domain service (validerar BR-GS2: state måste vara IDLE) + GameService.GameStartResult result = gameService.startGame(currentGameState, contestant); + + // Uppdatera state + currentGameState = result.newState(); + + // Poppa från kö + queueRepository.delete(queueItem.getTimestamp()); + + return contestant; + } + + /** + * UC-GF1: Finish game. + * + *

Avslutar pågående spel med resultat och uppdaterar ledartavla.

+ * + *

Preconditions:

+ *
    + *
  • Spel måste vara aktivt (game state == IN_PROGRESS)
  • + *
  • Contestant måste vara den aktiva spelaren
  • + *
+ * + *

Postconditions:

+ *
    + *
  • Game state = IDLE
  • + *
  • GameFinishedEvent emitteras
  • + *
  • Ledartavlan uppdateras (om resultatet är bättre eller nytt)
  • + *
  • Notification skickas
  • + *
+ * + * @param contestant contestant som avslutar + * @param result spelresultat + * @throws IllegalArgumentException om parametrar är null + * @throws IllegalStateException om preconditions inte är uppfyllda + */ + // @Transactional - Disabled: requires MongoDB replica set + public void finishGame(Contestant contestant, GameResult result) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + + // Avsluta spel via domain service (validerar BR-GS3, BR-GS4) + GameService.GameFinishResult finishResult = + gameService.finishGame(currentGameState, contestant, result); + + // Uppdatera state + currentGameState = finishResult.newState(); + + // Uppdatera ledartavla via domain service + Optional existingEntry = + leaderboardRepository.findByEmail(contestant.getEmail()); + + LeaderboardService.UpdateDecision decision = + leaderboardService.decideUpdate(contestant, result, existingEntry); + + // Spara endast om vi ska uppdatera (ADD eller UPDATE) + if (decision.shouldUpdate()) { + leaderboardRepository.save(decision.entry()); + } + } + + /** + * UC-GA1: Abort game. + * + *

Avbryter pågående spel utan resultat.

+ * + *

Preconditions:

+ *
    + *
  • Spel måste vara aktivt (game state == IN_PROGRESS)
  • + *
+ * + *

Postconditions:

+ *
    + *
  • Game state = IDLE
  • + *
  • GameAbortedEvent emitteras
  • + *
  • Notification skickas
  • + *
  • Ingen uppdatering av ledartavla
  • + *
+ * + * @throws IllegalStateException om inget spel är aktivt + */ + // @Transactional - Disabled: requires MongoDB replica set + public void abortGame() { + // Avbryt spel via domain service (validerar BR-GS3) + GameService.GameAbortResult result = gameService.abortGame(currentGameState); + + // Uppdatera state + currentGameState = result.newState(); + } + + /** + * Returnerar current game state. + * + *

Används för testing och debugging.

+ * + * @return current game state + */ + public GameState getCurrentGameState() { + return currentGameState; + } + + /** + * Resettar game state till IDLE. + * + *

Används endast för testing - tvingar reset av state.

+ */ + public void resetGameState() { + currentGameState = GameState.idle(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/application/LeaderboardApplicationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/application/LeaderboardApplicationService.java new file mode 100644 index 0000000..55ab7e8 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/application/LeaderboardApplicationService.java @@ -0,0 +1,102 @@ +package se.pumfoilgame.application; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.LeaderboardEntry; +import se.pumfoilgame.domain.port.LeaderboardRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Application Service för ledartavla (use cases). + * + *

Detta är en Application Service enligt Hexagonal Architecture som + * orchestrerar use cases för ledartavlan.

+ * + *

Responsibilities:

+ *
    + *
  • Hämta hela ledartavlan (sorterad efter endTime)
  • + *
  • Hämta top N entries
  • + *
  • Hantera transaktioner
  • + *
+ * + *

Use Cases:

+ *
    + *
  • UC-LF1: Find all leaderboard entries (sorterad)
  • + *
  • UC-LF2: Find top N entries
  • + *
  • UC-LF3: Find entry by email
  • + *
+ */ +@ApplicationScoped +public class LeaderboardApplicationService { + + private final LeaderboardRepository leaderboardRepository; + + @Inject + public LeaderboardApplicationService(LeaderboardRepository leaderboardRepository) { + this.leaderboardRepository = leaderboardRepository; + } + + /** + * UC-LF1: Find all leaderboard entries. + * + *

Returnerar hela ledartavlan sorterad efter endTime (bäst först).

+ * + * @return lista med entries sorterad efter endTime + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findAll() { + return leaderboardRepository.findAllSortedByEndTime(); + } + + /** + * UC-LF2: Find top N leaderboard entries. + * + *

Returnerar de N bästa entries sorterad efter endTime.

+ * + * @param limit antal entries att returnera + * @return lista med top N entries + * @throws IllegalArgumentException om limit är negativ eller noll + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findTopN(int limit) { + return leaderboardRepository.findTopN(limit); + } + + /** + * UC-LF3: Find entry by email. + * + *

Hämtar en specifik entry via contestant email.

+ * + * @param email contestant email + * @return Optional med entry om found + * @throws IllegalArgumentException om email är null eller blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public Optional findByEmail(String email) { + return leaderboardRepository.findByEmail(email); + } + + /** + * Räknar antal entries på ledartavlan. + * + * @return antal entries + */ + // @Transactional - Disabled: requires MongoDB replica set + public long count() { + return leaderboardRepository.count(); + } + + /** + * Kontrollerar om en contestant har en entry på ledartavlan. + * + * @param email contestant email + * @return true om entry finns + * @throws IllegalArgumentException om email är null eller blank + */ + // @Transactional - Disabled: requires MongoDB replica set + public boolean exists(String email) { + return leaderboardRepository.exists(email); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/application/QueueApplicationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/application/QueueApplicationService.java new file mode 100644 index 0000000..b0ea6ad --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/application/QueueApplicationService.java @@ -0,0 +1,96 @@ +package se.pumfoilgame.application; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.domain.port.QueueRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Application Service för kö-hantering (use cases). + * + *

Detta är en Application Service enligt Hexagonal Architecture som + * orchestrerar use cases för kön.

+ * + *

Responsibilities:

+ *
    + *
  • Hämta hela kön (sorterad FIFO)
  • + *
  • Ta bort item från kö
  • + *
  • Hantera transaktioner
  • + *
+ * + *

Use Cases:

+ *
    + *
  • UC-QF1: Find all queue items (FIFO)
  • + *
  • UC-QD1: Delete queue item by timestamp
  • + *
+ */ +@ApplicationScoped +public class QueueApplicationService { + + private final QueueRepository queueRepository; + + @Inject + public QueueApplicationService(QueueRepository queueRepository) { + this.queueRepository = queueRepository; + } + + /** + * UC-QF1: Find all queue items. + * + *

Returnerar kön sorterad efter timestamp (FIFO).

+ * + * @return lista med queue items i FIFO-ordning + */ + // @Transactional - Disabled: requires MongoDB replica set + public List findAll() { + return queueRepository.findAll(); + } + + /** + * UC-QF2: Find first queue item. + * + *

Returnerar första (äldsta) item i kön.

+ * + * @return Optional med första item om kön inte är tom + */ + // @Transactional - Disabled: requires MongoDB replica set + public Optional findFirst() { + return queueRepository.findFirst(); + } + + /** + * UC-QD1: Delete queue item. + * + *

Tar bort ett specifikt item från kön via timestamp.

+ * + * @param timestamp timestamp för item att ta bort + * @throws IllegalArgumentException om timestamp är negativ + */ + // @Transactional - Disabled: requires MongoDB replica set + public void delete(long timestamp) { + queueRepository.delete(timestamp); + } + + /** + * Kontrollerar om kön är tom. + * + * @return true om kön är tom + */ + // @Transactional - Disabled: requires MongoDB replica set + public boolean isEmpty() { + return queueRepository.isEmpty(); + } + + /** + * Räknar antal items i kön. + * + * @return antal items + */ + // @Transactional - Disabled: requires MongoDB replica set + public long count() { + return queueRepository.count(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/config/DomainServiceProducer.java b/backend-quarkus/src/main/java/se/pumfoilgame/config/DomainServiceProducer.java new file mode 100644 index 0000000..4706210 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/config/DomainServiceProducer.java @@ -0,0 +1,49 @@ +package se.pumfoilgame.config; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.port.NotificationService; +import se.pumfoilgame.domain.service.GameService; +import se.pumfoilgame.domain.service.LeaderboardService; + +/** + * CDI Producer för Domain Services. + * + *

Skapar och konfigurerar domain services för dependency injection.

+ * + *

Design Notes:

+ *
    + *
  • ApplicationScoped producer class
  • + *
  • @Produces methods för domain services
  • + *
  • Domain services är stateless - kan vara ApplicationScoped
  • + *
+ */ +@ApplicationScoped +public class DomainServiceProducer { + + @Inject + NotificationService notificationService; + + /** + * Producerar GameService med injicerad NotificationService. + * + * @return GameService instance + */ + @Produces + @ApplicationScoped + public GameService gameService() { + return new GameService(notificationService); + } + + /** + * Producerar LeaderboardService. + * + * @return LeaderboardService instance + */ + @Produces + @ApplicationScoped + public LeaderboardService leaderboardService() { + return new LeaderboardService(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/Contestant.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/Contestant.java new file mode 100644 index 0000000..904ae24 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/Contestant.java @@ -0,0 +1,111 @@ +package se.pumfoilgame.domain.model; + +import java.util.Objects; + +/** + * Contestant entity - representerar en tävlande i spelet. + * + *

Detta är en Entity enligt Domain-Driven Design, med email som unik identitet. + * Två Contestants är samma om de har samma email, oavsett name.

+ * + *

Business Rules:

+ *
    + *
  • Email får inte vara null eller blank (BR1)
  • + *
  • Name får inte vara null eller blank (BR2)
  • + *
  • Email måste vara unik i systemet (BR3)
  • + *
+ * + *

Immutability: Denna klass är immutable. Alla fields är final.

+ */ +public final class Contestant { + + private final String email; + private final String name; + + /** + * Skapar en ny Contestant. + * + * @param email contestant's email (unik identitet) + * @param name contestant's namn + * @throws IllegalArgumentException om email eller name är null eller blank + */ + public Contestant(String email, String name) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Name cannot be null or blank"); + } + + this.email = email.trim(); + this.name = name.trim(); + } + + /** + * Returnerar contestant's email (unik identitet). + * + * @return email + */ + public String getEmail() { + return email; + } + + /** + * Returnerar contestant's namn. + * + * @return name + */ + public String getName() { + return name; + } + + /** + * Skapar en ny Contestant med uppdaterat namn. + * Eftersom Contestant är immutable, returneras en ny instans. + * + * @param newName det nya namnet + * @return ny Contestant-instans med samma email men nytt namn + * @throws IllegalArgumentException om newName är null eller blank + */ + public Contestant withName(String newName) { + return new Contestant(this.email, newName); + } + + /** + * Två Contestants är samma om de har samma email (identity equality). + * + * @param obj objektet att jämföra med + * @return true om samma email, annars false + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Contestant other = (Contestant) obj; + return Objects.equals(email, other.email); + } + + /** + * Hash code baserad på email (identity). + * + * @return hash code + */ + @Override + public int hashCode() { + return Objects.hash(email); + } + + /** + * String representation för debugging. + * + * @return string representation + */ + @Override + public String toString() { + return "Contestant{email='" + email + "', name='" + name + "'}"; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameAbortedEvent.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameAbortedEvent.java new file mode 100644 index 0000000..e85e634 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameAbortedEvent.java @@ -0,0 +1,36 @@ +package se.pumfoilgame.domain.model; + +/** + * Domain Event - Spel avbrutet. + * + *

Detta event emitteras när ett pågående spel avbryts utan att avslutas med resultat. + * Namnet är i past tense (händelsen har redan inträffat).

+ * + *

Event Properties:

+ *
    + *
  • timestamp - När händelsen inträffade
  • + *
+ * + * @param timestamp när händelsen inträffade (Unix milliseconds) + */ +public record GameAbortedEvent( + long timestamp +) { + /** + * Compact constructor med validering. + */ + public GameAbortedEvent { + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + } + + /** + * Factory method för att skapa event med current timestamp. + * + * @return nytt GameAbortedEvent + */ + public static GameAbortedEvent now() { + return new GameAbortedEvent(System.currentTimeMillis()); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameFinishedEvent.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameFinishedEvent.java new file mode 100644 index 0000000..c30de5a --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameFinishedEvent.java @@ -0,0 +1,43 @@ +package se.pumfoilgame.domain.model; + +/** + * Domain Event - Spel avslutat. + * + *

Detta event emitteras när ett spel avslutas med ett resultat. + * Namnet är i past tense (händelsen har redan inträffat).

+ * + *

Event Properties:

+ *
    + *
  • result - Spelresultatet
  • + *
  • timestamp - När händelsen inträffade
  • + *
+ * + * @param result spelresultat + * @param timestamp när händelsen inträffade (Unix milliseconds) + */ +public record GameFinishedEvent( + GameResult result, + long timestamp +) { + /** + * Compact constructor med validering. + */ + public GameFinishedEvent { + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + } + + /** + * Factory method för att skapa event med current timestamp. + * + * @param result spelresultat + * @return nytt GameFinishedEvent + */ + public static GameFinishedEvent now(GameResult result) { + return new GameFinishedEvent(result, System.currentTimeMillis()); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameResult.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameResult.java new file mode 100644 index 0000000..5f584b7 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameResult.java @@ -0,0 +1,78 @@ +package se.pumfoilgame.domain.model; + +/** + * GameResult value object - representerar resultatet av ett avslutat spel. + * + *

Detta är en Value Object enligt Domain-Driven Design, vilket innebär att den är: + *

    + *
  • Immutable - Alla fields är final, inga setters
  • + *
  • Defined by attributes - Två GameResult med samma värden är samma
  • + *
  • Self-validating - Validering i konstruktor
  • + *
+ * + *

Business Rules:

+ *
    + *
  • endTime måste vara >= splitTime (BR6)
  • + *
  • splitTime och endTime måste vara >= 0
  • + *
+ * + *

Implementerad som Java Record för maximal immutability och minimal boilerplate.

+ * + * @param splitTime tid vid mellantiden (sekunder) + * @param endTime total tid vid målgång (sekunder) + */ +public record GameResult(double splitTime, double endTime) { + + /** + * Compact constructor som validerar business rules. + * + * @throws IllegalArgumentException om endTime < splitTime eller om någon tid är negativ + */ + public GameResult { + if (splitTime < 0) { + throw new IllegalArgumentException("Split time cannot be negative"); + } + if (endTime < 0) { + throw new IllegalArgumentException("End time cannot be negative"); + } + if (endTime < splitTime) { + throw new IllegalArgumentException( + "End time (" + endTime + ") cannot be less than split time (" + splitTime + ")" + ); + } + } + + /** + * Jämför detta resultat med ett annat för att avgöra vilket som är snabbare. + * + *

Ett resultat är "snabbare" om det har lägre endTime.

+ * + * @param other det andra resultatet att jämföra med + * @return true om detta resultat är snabbare (lägre endTime), annars false + * @throws NullPointerException om other är null + */ + public boolean isFasterThan(GameResult other) { + if (other == null) { + throw new NullPointerException("Cannot compare with null result"); + } + return this.endTime < other.endTime; + } + + /** + * Returnerar skillnaden mellan målgång och mellantid. + * + * @return tiden från split till mål (sekunder) + */ + public double getSecondHalfTime() { + return endTime - splitTime; + } + + /** + * Kontrollerar om detta är ett giltigt resultat (alla tider är positiva). + * + * @return true om giltigt resultat + */ + public boolean isValid() { + return splitTime >= 0 && endTime >= 0 && endTime >= splitTime; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameStartedEvent.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameStartedEvent.java new file mode 100644 index 0000000..7610ad6 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameStartedEvent.java @@ -0,0 +1,49 @@ +package se.pumfoilgame.domain.model; + +/** + * Domain Event - Spel startat. + * + *

Detta event emitteras när ett nytt spel startas med en contestant. + * Namnet är i past tense (händelsen har redan inträffat).

+ * + *

Event Properties:

+ *
    + *
  • contestant - Vilken contestant som börjar spela
  • + *
  • timestamp - När händelsen inträffade
  • + *
+ * + *

Usage:

+ *
+ * GameStartedEvent event = new GameStartedEvent(contestant, System.currentTimeMillis());
+ * // Event kan användas för logging, notifications, event sourcing, etc.
+ * 
+ * + * @param contestant contestant som börjar spela + * @param timestamp när händelsen inträffade (Unix milliseconds) + */ +public record GameStartedEvent( + Contestant contestant, + long timestamp +) { + /** + * Compact constructor med validering. + */ + public GameStartedEvent { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + } + + /** + * Factory method för att skapa event med current timestamp. + * + * @param contestant contestant som börjar spela + * @return nytt GameStartedEvent + */ + public static GameStartedEvent now(Contestant contestant) { + return new GameStartedEvent(contestant, System.currentTimeMillis()); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameState.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameState.java new file mode 100644 index 0000000..d7df2bd --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/GameState.java @@ -0,0 +1,129 @@ +package se.pumfoilgame.domain.model; + +import java.util.Optional; + +/** + * GameState value object - representerar det aktuella speltillståndet. + * + *

Detta är en Value Object enligt Domain-Driven Design som kapslar in spelstatus. + * GameState är immutable och innehåller information om spelet är aktivt och vilken + * contestant som spelar.

+ * + *

States:

+ *
    + *
  • IDLE - Inget spel pågår, redo för nytt spel
  • + *
  • IN_PROGRESS - Ett spel pågår med en specifik contestant
  • + *
+ * + *

Business Rules:

+ *
    + *
  • Endast ett spel kan vara aktivt åt gången (BR - GameState Invariant)
  • + *
  • IN_PROGRESS state måste ha en contestant
  • + *
  • IDLE state får inte ha en contestant
  • + *
+ * + *

Använd factory methods {@link #idle()} och {@link #inProgress(Contestant)} för att + * skapa instanser.

+ * + * @param type state-typ (IDLE eller IN_PROGRESS) + * @param currentContestant contestant som spelar (endast för IN_PROGRESS) + */ +public record GameState(GameStateType type, Optional currentContestant) { + + /** + * GameState types. + */ + public enum GameStateType { + /** Inget spel pågår */ + IDLE, + /** Ett spel pågår */ + IN_PROGRESS + } + + /** + * Compact constructor som validerar business rules. + * + * @throws IllegalArgumentException om state är inkonsistent + */ + public GameState { + if (type == null) { + throw new IllegalArgumentException("GameStateType cannot be null"); + } + if (currentContestant == null) { + throw new IllegalArgumentException("CurrentContestant cannot be null (use Optional.empty())"); + } + + // Business rule: IN_PROGRESS måste ha contestant + if (type == GameStateType.IN_PROGRESS && currentContestant.isEmpty()) { + throw new IllegalArgumentException("IN_PROGRESS state must have a contestant"); + } + + // Business rule: IDLE får inte ha contestant + if (type == GameStateType.IDLE && currentContestant.isPresent()) { + throw new IllegalArgumentException("IDLE state cannot have a contestant"); + } + } + + /** + * Factory method för IDLE state. + * + * @return ny GameState i IDLE state + */ + public static GameState idle() { + return new GameState(GameStateType.IDLE, Optional.empty()); + } + + /** + * Factory method för IN_PROGRESS state. + * + * @param contestant contestant som spelar + * @return ny GameState i IN_PROGRESS state + * @throws IllegalArgumentException om contestant är null + */ + public static GameState inProgress(Contestant contestant) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null for IN_PROGRESS state"); + } + return new GameState(GameStateType.IN_PROGRESS, Optional.of(contestant)); + } + + /** + * Kontrollerar om spelet är i IDLE state. + * + * @return true om IDLE, annars false + */ + public boolean isIdle() { + return type == GameStateType.IDLE; + } + + /** + * Kontrollerar om ett spel pågår (IN_PROGRESS). + * + * @return true om IN_PROGRESS, annars false + */ + public boolean isInProgress() { + return type == GameStateType.IN_PROGRESS; + } + + /** + * Returnerar contestant som spelar just nu (endast för IN_PROGRESS). + * + * @return Optional med contestant om IN_PROGRESS, annars Optional.empty() + */ + public Optional getCurrentContestant() { + return currentContestant; + } + + /** + * Kontrollerar om denna state representerar samma contestant som spelar. + * + * @param contestant contestant att jämföra med + * @return true om IN_PROGRESS och samma contestant + */ + public boolean isPlayingContestant(Contestant contestant) { + if (contestant == null) { + return false; + } + return isInProgress() && currentContestant.map(c -> c.equals(contestant)).orElse(false); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/LeaderboardEntry.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/LeaderboardEntry.java new file mode 100644 index 0000000..9384cdb --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/LeaderboardEntry.java @@ -0,0 +1,146 @@ +package se.pumfoilgame.domain.model; + +import java.util.Objects; + +/** + * LeaderboardEntry entity - representerar en post på ledartavlan. + * + *

Detta är en Entity enligt Domain-Driven Design, med contestant email som unik identitet. + * Två LeaderboardEntries är samma om de har samma contestant (email), oavsett result.

+ * + *

Business Rules:

+ *
    + *
  • Contestant får inte vara null
  • + *
  • Result får inte vara null
  • + *
  • Varje contestant kan bara ha en post på ledartavlan (BR - Leaderboard Uniqueness)
  • + *
  • Ledartavlan innehåller alltid bästa resultatet för varje contestant
  • + *
+ * + *

Immutability: Denna klass är immutable. Alla fields är final. + * För att uppdatera result, använd {@link #withResult(GameResult)} som returnerar en ny instans.

+ */ +public final class LeaderboardEntry { + + private final Contestant contestant; + private final GameResult result; + + /** + * Skapar en ny LeaderboardEntry. + * + * @param contestant contestant som äger denna post + * @param result spelresultat för denna post + * @throws IllegalArgumentException om contestant eller result är null + */ + public LeaderboardEntry(Contestant contestant, GameResult result) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + + this.contestant = contestant; + this.result = result; + } + + /** + * Returnerar contestant som äger denna post. + * + * @return contestant + */ + public Contestant getContestant() { + return contestant; + } + + /** + * Returnerar spelresultatet för denna post. + * + * @return result + */ + public GameResult getResult() { + return result; + } + + /** + * Skapar en ny LeaderboardEntry med uppdaterat resultat. + * Eftersom LeaderboardEntry är immutable, returneras en ny instans. + * + * @param newResult det nya resultatet + * @return ny LeaderboardEntry-instans med samma contestant men nytt result + * @throws IllegalArgumentException om newResult är null + */ + public LeaderboardEntry withResult(GameResult newResult) { + return new LeaderboardEntry(this.contestant, newResult); + } + + /** + * Kontrollerar om ett nytt resultat är bättre än nuvarande. + * Ett resultat är "bättre" om det har lägre endTime. + * + * @param newResult det nya resultatet att jämföra med + * @return true om newResult är bättre (snabbare) än nuvarande result + * @throws NullPointerException om newResult är null + */ + public boolean shouldUpdateWith(GameResult newResult) { + if (newResult == null) { + throw new NullPointerException("Cannot compare with null result"); + } + return newResult.isFasterThan(this.result); + } + + /** + * Returnerar email för contestant (för sortering och lookups). + * + * @return contestant email + */ + public String getContestantEmail() { + return contestant.getEmail(); + } + + /** + * Returnerar endTime för resultat (för sortering av ledartavlan). + * + * @return result endTime + */ + public double getEndTime() { + return result.endTime(); + } + + /** + * Två LeaderboardEntries är samma om de har samma contestant email (identity equality). + * + * @param obj objektet att jämföra med + * @return true om samma contestant email, annars false + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + LeaderboardEntry other = (LeaderboardEntry) obj; + return Objects.equals(contestant.getEmail(), other.contestant.getEmail()); + } + + /** + * Hash code baserad på contestant email (identity). + * + * @return hash code + */ + @Override + public int hashCode() { + return Objects.hash(contestant.getEmail()); + } + + /** + * String representation för debugging. + * + * @return string representation + */ + @Override + public String toString() { + return "LeaderboardEntry{contestant=" + contestant + ", result=" + result + "}"; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/QueueItem.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/QueueItem.java new file mode 100644 index 0000000..44dd892 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/model/QueueItem.java @@ -0,0 +1,127 @@ +package se.pumfoilgame.domain.model; + +import java.util.Objects; + +/** + * QueueItem entity - representerar en post i kön av väntande contestants. + * + *

Detta är en Entity enligt Domain-Driven Design, med timestamp som unik identitet. + * Två QueueItems är samma om de har samma timestamp, oavsett contestant.

+ * + *

Business Rules:

+ *
    + *
  • Timestamp måste vara >= 0
  • + *
  • Contestant får inte vara null
  • + *
  • Timestamp är unik (garanteras av repository-lager)
  • + *
  • Kön är sorterad efter timestamp (FIFO - First In, First Out)
  • + *
+ * + *

Immutability: Denna klass är immutable. Alla fields är final.

+ */ +public final class QueueItem { + + private final long timestamp; + private final Contestant contestant; + + /** + * Skapar en ny QueueItem. + * + * @param timestamp tidsstämpel när contestant lades till i kön (Unix milliseconds) + * @param contestant contestant som står i kön + * @throws IllegalArgumentException om timestamp är negativ eller contestant är null + */ + public QueueItem(long timestamp, Contestant contestant) { + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + + this.timestamp = timestamp; + this.contestant = contestant; + } + + /** + * Factory method för att skapa en QueueItem med current timestamp. + * + * @param contestant contestant att lägga till i kön + * @return ny QueueItem med timestamp = System.currentTimeMillis() + * @throws IllegalArgumentException om contestant är null + */ + public static QueueItem now(Contestant contestant) { + return new QueueItem(System.currentTimeMillis(), contestant); + } + + /** + * Returnerar timestamp (unik identitet). + * + * @return timestamp + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Returnerar contestant som står i kön. + * + * @return contestant + */ + public Contestant getContestant() { + return contestant; + } + + /** + * Jämför denna QueueItem med en annan för att avgöra vilken som är äldst. + * + *

Används för att sortera kön i FIFO-ordning (lägst timestamp först).

+ * + * @param other den andra QueueItem att jämföra med + * @return true om denna item är äldre (lägre timestamp), annars false + * @throws NullPointerException om other är null + */ + public boolean isOlderThan(QueueItem other) { + if (other == null) { + throw new NullPointerException("Cannot compare with null QueueItem"); + } + return this.timestamp < other.timestamp; + } + + /** + * Två QueueItems är samma om de har samma timestamp (identity equality). + * + * @param obj objektet att jämföra med + * @return true om samma timestamp, annars false + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + QueueItem other = (QueueItem) obj; + return timestamp == other.timestamp; + } + + /** + * Hash code baserad på timestamp (identity). + * + * @return hash code + */ + @Override + public int hashCode() { + return Objects.hash(timestamp); + } + + /** + * String representation för debugging. + * + * @return string representation + */ + @Override + public String toString() { + return "QueueItem{timestamp=" + timestamp + ", contestant=" + contestant + "}"; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/ContestantRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/ContestantRepository.java new file mode 100644 index 0000000..974132c --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/ContestantRepository.java @@ -0,0 +1,96 @@ +package se.pumfoilgame.domain.port; + +import se.pumfoilgame.domain.model.Contestant; + +import java.util.List; +import java.util.Optional; + +/** + * Repository port för Contestant aggregate. + * + *

Detta är en port (interface) enligt Hexagonal Architecture som definierar + * operationer för persistence av Contestants. Implementationen finns i infrastructure-lagret.

+ * + *

Responsibilities:

+ *
    + *
  • CRUD-operationer för Contestants
  • + *
  • Filtering baserat på queue-status (enqueued/not enqueued)
  • + *
  • Lookup via email (unique identifier)
  • + *
+ * + *

Design Notes:

+ *
    + *
  • Collection-like interface (Repository pattern)
  • + *
  • Returnerar domain objects (Contestant), inte entities
  • + *
  • Ingen dependency på infrastructure
  • + *
+ */ +public interface ContestantRepository { + + /** + * Hämtar alla contestants i systemet. + * + * @return lista av alla contestants, tom lista om inga finns + */ + List findAll(); + + /** + * Hämtar alla contestants som finns i kön. + * + *

En contestant är "enqueued" om det finns en QueueItem för den.

+ * + * @return lista av enqueued contestants, tom lista om inga finns + */ + List findEnqueued(); + + /** + * Hämtar alla contestants som INTE finns i kön. + * + *

En contestant är "not enqueued" om det INTE finns en QueueItem för den.

+ * + * @return lista av not enqueued contestants, tom lista om inga finns + */ + List findNotEnqueued(); + + /** + * Hämtar en contestant via email. + * + * @param email contestant's email (unique identifier) + * @return Optional med contestant om hittad, annars Optional.empty() + * @throws IllegalArgumentException om email är null eller blank + */ + Optional findByEmail(String email); + + /** + * Sparar en contestant (insert eller update). + * + *

Om en contestant med samma email redan finns, uppdateras den. + * Annars skapas en ny.

+ * + * @param contestant contestant att spara + * @return den sparade contestant + * @throws IllegalArgumentException om contestant är null + */ + Contestant save(Contestant contestant); + + /** + * Tar bort en contestant via email. + * + *

Business Rule: Denna operation tar endast bort + * contestant-posten. Relaterade QueueItems måste hanteras separat + * av use case-lagret.

+ * + * @param email email för contestant att ta bort + * @throws IllegalArgumentException om email är null eller blank + */ + void delete(String email); + + /** + * Kontrollerar om en contestant finns i systemet. + * + * @param email contestant's email + * @return true om contestant finns, annars false + * @throws IllegalArgumentException om email är null eller blank + */ + boolean exists(String email); +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/LeaderboardRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/LeaderboardRepository.java new file mode 100644 index 0000000..8e7b8f3 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/LeaderboardRepository.java @@ -0,0 +1,108 @@ +package se.pumfoilgame.domain.port; + +import se.pumfoilgame.domain.model.LeaderboardEntry; + +import java.util.List; +import java.util.Optional; + +/** + * Repository port för LeaderboardEntry aggregate. + * + *

Detta är en port (interface) enligt Hexagonal Architecture som definierar + * operationer för persistence av LeaderboardEntries (ledartavlan).

+ * + *

Responsibilities:

+ *
    + *
  • CRUD-operationer för LeaderboardEntries
  • + *
  • Sortering efter endTime (bästa resultat först)
  • + *
  • Lookup via contestant email (unique identifier)
  • + *
+ * + *

Business Rules:

+ *
    + *
  • Ledartavlan sorteras alltid efter endTime (lägst först = bäst)
  • + *
  • Varje contestant kan bara ha en post på ledartavlan
  • + *
  • Ledartavlan innehåller alltid bästa resultatet för varje contestant
  • + *
+ */ +public interface LeaderboardRepository { + + /** + * Hämtar alla LeaderboardEntries sorterade efter endTime (bäst först). + * + *

Returnerar alltid sorterad lista med bästa (lägsta) tiden först.

+ * + * @return lista av LeaderboardEntries sorterad efter endTime, tom lista om inga finns + */ + List findAllSortedByEndTime(); + + /** + * Hämtar en LeaderboardEntry via contestant email. + * + * @param email contestant's email (unique identifier) + * @return Optional med LeaderboardEntry om hittad, annars Optional.empty() + * @throws IllegalArgumentException om email är null eller blank + */ + Optional findByEmail(String email); + + /** + * Hämtar top N entries från ledartavlan. + * + *

Returnerar de N bästa (snabbaste) resultaten.

+ * + * @param limit antal entries att returnera + * @return lista av top N entries, sorterad efter endTime + * @throws IllegalArgumentException om limit är negativ eller noll + */ + List findTopN(int limit); + + /** + * Sparar en LeaderboardEntry (insert eller update). + * + *

Om en entry för samma contestant redan finns, uppdateras den. + * Annars skapas en ny.

+ * + *

Business Rule: Denna metod sparar direkt utan att + * kontrollera om resultatet är bättre. Det är use case-lagrets ansvar + * att endast anropa save() för bättre resultat.

+ * + * @param entry LeaderboardEntry att spara + * @return den sparade LeaderboardEntry + * @throws IllegalArgumentException om entry är null + */ + LeaderboardEntry save(LeaderboardEntry entry); + + /** + * Tar bort en LeaderboardEntry via contestant email. + * + *

Note: Denna operation används sällan i produktionskod. + * Primärt för test-cleanup.

+ * + * @param email email för contestant vars entry ska tas bort + * @throws IllegalArgumentException om email är null eller blank + */ + void delete(String email); + + /** + * Tar bort alla LeaderboardEntries (tömmer ledartavlan). + * + *

Används vid system-reset eller test-cleanup.

+ */ + void deleteAll(); + + /** + * Kontrollerar om en contestant har en post på ledartavlan. + * + * @param email contestant's email + * @return true om contestant har en entry, annars false + * @throws IllegalArgumentException om email är null eller blank + */ + boolean exists(String email); + + /** + * Räknar antal entries på ledartavlan. + * + * @return antal entries + */ + long count(); +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/NotificationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/NotificationService.java new file mode 100644 index 0000000..6705ae5 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/NotificationService.java @@ -0,0 +1,105 @@ +package se.pumfoilgame.domain.port; + +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.GameResult; + +/** + * Notification service port för att skicka real-time notifieringar. + * + *

Detta är en port (interface) enligt Hexagonal Architecture som definierar + * operationer för att notifiera externa system (WebSocket-klienter) om spelhändelser.

+ * + *

Responsibilities:

+ *
    + *
  • Broadcast av game events till alla anslutna klienter
  • + *
  • Abstraherar bort implementationsdetaljer (WebSocket, etc.)
  • + *
  • Definierar vilka events som kan skickas
  • + *
+ * + *

Notification Types:

+ *
    + *
  • InitGame - Spel startat med contestant info
  • + *
  • EndGame - Spel avslutat med resultat
  • + *
  • AbortGame - Spel avbrutet
  • + *
+ * + *

Design Notes:

+ *
    + *
  • Fire-and-forget semantics (ingen retry-logik)
  • + *
  • Asynkron broadcast till alla anslutna klienter
  • + *
  • Implementationen finns i infrastructure-lagret
  • + *
+ */ +public interface NotificationService { + + /** + * Notifierar att ett spel har startat. + * + *

Skickar InitGame-notification till alla anslutna WebSocket-klienter + * med information om vilken contestant som börjar spela.

+ * + *

Notification format:

+ *
+     * {
+     *   "type": "InitGame",
+     *   "userid": "contestant@example.com",
+     *   "name": "Contestant Name",
+     *   "splitTime": null,
+     *   "finishTime": null
+     * }
+     * 
+ * + * @param contestant contestant som börjar spela + * @throws IllegalArgumentException om contestant är null + */ + void notifyGameStarted(Contestant contestant); + + /** + * Notifierar att ett spel har avslutats. + * + *

Skickar EndGame-notification till alla anslutna WebSocket-klienter + * med spelresultatet.

+ * + *

Notification format:

+ *
+     * {
+     *   "type": "EndGame",
+     *   "userid": null,
+     *   "name": null,
+     *   "splitTime": 15.5,
+     *   "finishTime": 32.8
+     * }
+     * 
+ * + * @param result spelresultat + * @throws IllegalArgumentException om result är null + */ + void notifyGameFinished(GameResult result); + + /** + * Notifierar att ett spel har avbrutits. + * + *

Skickar AbortGame-notification till alla anslutna WebSocket-klienter.

+ * + *

Notification format:

+ *
+     * {
+     *   "type": "AbortGame",
+     *   "userid": null,
+     *   "name": null,
+     *   "splitTime": null,
+     *   "finishTime": null
+     * }
+     * 
+ */ + void notifyGameAborted(); + + /** + * Returnerar antal anslutna klienter. + * + *

Används för monitoring och logging.

+ * + * @return antal aktiva WebSocket-sessioner + */ + int getConnectedClientsCount(); +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/QueueRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/QueueRepository.java new file mode 100644 index 0000000..9ef480f --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/port/QueueRepository.java @@ -0,0 +1,99 @@ +package se.pumfoilgame.domain.port; + +import se.pumfoilgame.domain.model.QueueItem; + +import java.util.List; +import java.util.Optional; + +/** + * Repository port för QueueItem aggregate. + * + *

Detta är en port (interface) enligt Hexagonal Architecture som definierar + * operationer för persistence av QueueItems (kön av väntande contestants).

+ * + *

Responsibilities:

+ *
    + *
  • CRUD-operationer för QueueItems
  • + *
  • FIFO-ordning (sorterad efter timestamp)
  • + *
  • Lookup via timestamp (unique identifier)
  • + *
+ * + *

Business Rules:

+ *
    + *
  • Kön är alltid sorterad efter timestamp (äldst först)
  • + *
  • Varje contestant kan bara finnas i kön en gång
  • + *
  • Timestamp är unik
  • + *
+ */ +public interface QueueRepository { + + /** + * Hämtar alla QueueItems sorterade efter timestamp (FIFO). + * + *

Returnerar alltid sorterad lista med äldsta item först (lägst timestamp).

+ * + * @return lista av QueueItems i FIFO-ordning, tom lista om kön är tom + */ + List findAll(); + + /** + * Hämtar en QueueItem via timestamp. + * + * @param timestamp unique timestamp för QueueItem + * @return Optional med QueueItem om hittad, annars Optional.empty() + * @throws IllegalArgumentException om timestamp är negativ + */ + Optional findByTimestamp(long timestamp); + + /** + * Hämtar första (äldsta) QueueItem i kön. + * + *

Detta är den QueueItem som har lägst timestamp och skulle + * vara nästa att startas i ett spel.

+ * + * @return Optional med första QueueItem om kön inte är tom, annars Optional.empty() + */ + Optional findFirst(); + + /** + * Sparar en QueueItem. + * + *

Business Rule: Om en QueueItem med samma timestamp + * redan finns, kastas exception (timestamps måste vara unika).

+ * + * @param queueItem QueueItem att spara + * @return den sparade QueueItem + * @throws IllegalArgumentException om queueItem är null + * @throws IllegalStateException om timestamp redan finns + */ + QueueItem save(QueueItem queueItem); + + /** + * Tar bort en QueueItem via timestamp. + * + * @param timestamp timestamp för QueueItem att ta bort + * @throws IllegalArgumentException om timestamp är negativ + */ + void delete(long timestamp); + + /** + * Tar bort alla QueueItems (tömmer kön). + * + *

Används vid system-reset eller test-cleanup.

+ */ + void deleteAll(); + + /** + * Kontrollerar om kön är tom. + * + * @return true om inga QueueItems finns, annars false + */ + boolean isEmpty(); + + /** + * Räknar antal QueueItems i kön. + * + * @return antal items i kön + */ + long count(); +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/GameService.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/GameService.java new file mode 100644 index 0000000..6a40e40 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/GameService.java @@ -0,0 +1,238 @@ +package se.pumfoilgame.domain.service; + +import se.pumfoilgame.domain.model.*; +import se.pumfoilgame.domain.port.NotificationService; + +import java.util.Optional; + +/** + * Domain Service för spellogik. + * + *

Detta är en Domain Service enligt Domain-Driven Design som innehåller + * business logic som inte naturligt passar i en Entity eller Value Object.

+ * + *

Responsibilities:

+ *
    + *
  • Validera och starta spel
  • + *
  • Avsluta spel med resultat
  • + *
  • Avbryta pågående spel
  • + *
  • Emittera domain events
  • + *
  • Skicka notifieringar via NotificationService
  • + *
+ * + *

Business Rules:

+ *
    + *
  • BR-GS1: Endast ett spel kan vara aktivt åt gången
  • + *
  • BR-GS2: Game state måste vara IDLE för att starta nytt spel
  • + *
  • BR-GS3: Game state måste vara IN_PROGRESS för att avsluta/avbryta
  • + *
  • BR-GS4: Endast den aktiva contestant kan avsluta sitt spel
  • + *
+ * + *

Design Notes:

+ *
    + *
  • Stateless - ingen intern state
  • + *
  • Pure functions - alla metoder är side-effect free förutom notifications
  • + *
  • Dependency på NotificationService (port)
  • + *
  • Emitterar domain events som return values
  • + *
+ */ +public class GameService { + + private final NotificationService notificationService; + + /** + * Skapar en ny GameService. + * + * @param notificationService service för att skicka notifieringar + * @throws IllegalArgumentException om notificationService är null + */ + public GameService(NotificationService notificationService) { + if (notificationService == null) { + throw new IllegalArgumentException("NotificationService cannot be null"); + } + this.notificationService = notificationService; + } + + /** + * Startar ett nytt spel. + * + *

Validerar business rules och returnerar nytt game state samt event.

+ * + *

Business Rules:

+ *
    + *
  • BR-GS2: Game state måste vara IDLE
  • + *
  • BR-GS1: Skapar IN_PROGRESS state (endast ett aktivt spel)
  • + *
+ * + * @param currentState nuvarande game state + * @param contestant contestant som ska spela + * @return result med nytt state och event + * @throws IllegalArgumentException om contestant är null + * @throws IllegalStateException om game state inte är IDLE + */ + public GameStartResult startGame(GameState currentState, Contestant contestant) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (currentState == null) { + throw new IllegalArgumentException("Current state cannot be null"); + } + + // BR-GS2: Måste vara IDLE för att starta + if (!currentState.isIdle()) { + throw new IllegalStateException( + "Cannot start game - game is already in progress with contestant: " + + currentState.currentContestant().map(Contestant::getEmail).orElse("unknown") + ); + } + + // Skapa nytt state och event + GameState newState = GameState.inProgress(contestant); + GameStartedEvent event = GameStartedEvent.now(contestant); + + // Skicka notification + notificationService.notifyGameStarted(contestant); + + return new GameStartResult(newState, event); + } + + /** + * Avslutar ett pågående spel med resultat. + * + *

Validerar business rules och returnerar nytt state samt event.

+ * + *

Business Rules:

+ *
    + *
  • BR-GS3: Game state måste vara IN_PROGRESS
  • + *
  • BR-GS4: Endast den aktiva contestant kan avsluta sitt spel
  • + *
+ * + * @param currentState nuvarande game state + * @param contestant contestant som avslutar spelet + * @param result spelresultatet + * @return result med nytt state och event + * @throws IllegalArgumentException om någon parameter är null + * @throws IllegalStateException om game state inte är IN_PROGRESS + * @throws IllegalStateException om contestant inte är den aktiva spelaren + */ + public GameFinishResult finishGame(GameState currentState, Contestant contestant, GameResult result) { + if (currentState == null) { + throw new IllegalArgumentException("Current state cannot be null"); + } + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + + // BR-GS3: Måste vara IN_PROGRESS + if (!currentState.isInProgress()) { + throw new IllegalStateException("Cannot finish game - no game is in progress"); + } + + // BR-GS4: Endast aktiv contestant kan avsluta + if (!currentState.isPlayingContestant(contestant)) { + throw new IllegalStateException( + "Cannot finish game - contestant " + contestant.getEmail() + + " is not the active player. Active player: " + + currentState.currentContestant().map(Contestant::getEmail).orElse("none") + ); + } + + // Skapa nytt state och event + GameState newState = GameState.idle(); + GameFinishedEvent event = GameFinishedEvent.now(result); + + // Skicka notification + notificationService.notifyGameFinished(result); + + return new GameFinishResult(newState, event); + } + + /** + * Avbryter ett pågående spel utan resultat. + * + *

Används när ett spel måste avbrytas (timeout, fel, användaravbrott).

+ * + *

Business Rules:

+ *
    + *
  • BR-GS3: Game state måste vara IN_PROGRESS
  • + *
+ * + * @param currentState nuvarande game state + * @return result med nytt state och event + * @throws IllegalArgumentException om currentState är null + * @throws IllegalStateException om game state inte är IN_PROGRESS + */ + public GameAbortResult abortGame(GameState currentState) { + if (currentState == null) { + throw new IllegalArgumentException("Current state cannot be null"); + } + + // BR-GS3: Måste vara IN_PROGRESS + if (!currentState.isInProgress()) { + throw new IllegalStateException("Cannot abort game - no game is in progress"); + } + + // Skapa nytt state och event + GameState newState = GameState.idle(); + GameAbortedEvent event = GameAbortedEvent.now(); + + // Skicka notification + notificationService.notifyGameAborted(); + + return new GameAbortResult(newState, event); + } + + /** + * Result record för startGame operation. + * + * @param newState nytt game state efter start + * @param event domain event som emitterades + */ + public record GameStartResult(GameState newState, GameStartedEvent event) { + public GameStartResult { + if (newState == null) { + throw new IllegalArgumentException("New state cannot be null"); + } + if (event == null) { + throw new IllegalArgumentException("Event cannot be null"); + } + } + } + + /** + * Result record för finishGame operation. + * + * @param newState nytt game state efter finish + * @param event domain event som emitterades + */ + public record GameFinishResult(GameState newState, GameFinishedEvent event) { + public GameFinishResult { + if (newState == null) { + throw new IllegalArgumentException("New state cannot be null"); + } + if (event == null) { + throw new IllegalArgumentException("Event cannot be null"); + } + } + } + + /** + * Result record för abortGame operation. + * + * @param newState nytt game state efter abort + * @param event domain event som emitterades + */ + public record GameAbortResult(GameState newState, GameAbortedEvent event) { + public GameAbortResult { + if (newState == null) { + throw new IllegalArgumentException("New state cannot be null"); + } + if (event == null) { + throw new IllegalArgumentException("Event cannot be null"); + } + } + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/LeaderboardService.java b/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/LeaderboardService.java new file mode 100644 index 0000000..b0c34d1 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/domain/service/LeaderboardService.java @@ -0,0 +1,203 @@ +package se.pumfoilgame.domain.service; + +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.GameResult; +import se.pumfoilgame.domain.model.LeaderboardEntry; + +import java.util.Optional; + +/** + * Domain Service för ledartavlelogik. + * + *

Detta är en Domain Service enligt Domain-Driven Design som innehåller + * business logic för ledartavlan som inte naturligt passar i en Entity.

+ * + *

Responsibilities:

+ *
    + *
  • Avgöra om ett nytt resultat ska uppdatera ledartavlan
  • + *
  • Skapa nya eller uppdaterade leaderboard entries
  • + *
  • Validera business rules för leaderboard-uppdateringar
  • + *
+ * + *

Business Rules:

+ *
    + *
  • BR-LB1: Endast bättre resultat (lägre endTime) uppdaterar ledartavlan
  • + *
  • BR-LB2: Varje contestant kan bara ha en post på ledartavlan
  • + *
  • BR-LB3: Första resultatet för en contestant läggs alltid till
  • + *
+ * + *

Design Notes:

+ *
    + *
  • Stateless - ingen intern state
  • + *
  • Pure functions - alla metoder är side-effect free
  • + *
  • Ingen dependency på repositories (coordination sker i application layer)
  • + *
+ */ +public class LeaderboardService { + + /** + * Skapar en ny LeaderboardService. + */ + public LeaderboardService() { + // Stateless service - ingen initialization behövs + } + + /** + * Avgör om ett nytt resultat ska uppdatera ledartavlan. + * + *

Returnerar UpdateDecision med information om vad som ska göras.

+ * + *

Business Rules:

+ *
    + *
  • BR-LB3: Om ingen existing entry finns, lägg till nytt resultat
  • + *
  • BR-LB1: Om existing entry finns, uppdatera endast om nytt resultat är bättre
  • + *
+ * + * @param contestant contestant som har spelat + * @param newResult nytt spelresultat + * @param existingEntry optional existing leaderboard entry + * @return decision om vad som ska göras med resultatet + * @throws IllegalArgumentException om contestant eller newResult är null + */ + public UpdateDecision decideUpdate(Contestant contestant, GameResult newResult, + Optional existingEntry) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (newResult == null) { + throw new IllegalArgumentException("New result cannot be null"); + } + if (existingEntry == null) { + throw new IllegalArgumentException("Existing entry cannot be null (use Optional.empty())"); + } + + if (existingEntry.isEmpty()) { + // BR-LB3: Första resultatet - lägg alltid till + LeaderboardEntry newEntry = new LeaderboardEntry(contestant, newResult); + return new UpdateDecision(UpdateAction.ADD, newEntry, null); + } + + LeaderboardEntry existing = existingEntry.get(); + + // BR-LB1: Uppdatera endast om nytt resultat är bättre + if (existing.shouldUpdateWith(newResult)) { + LeaderboardEntry updatedEntry = existing.withResult(newResult); + return new UpdateDecision(UpdateAction.UPDATE, updatedEntry, existing); + } + + // Behåll existing - nytt resultat är inte bättre + return new UpdateDecision(UpdateAction.KEEP, existing, existing); + } + + /** + * Skapar en ny leaderboard entry för en contestant. + * + *

Används när en contestant får sitt första resultat.

+ * + * @param contestant contestant + * @param result spelresultat + * @return ny leaderboard entry + * @throws IllegalArgumentException om någon parameter är null + */ + public LeaderboardEntry createEntry(Contestant contestant, GameResult result) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + return new LeaderboardEntry(contestant, result); + } + + /** + * Uppdaterar en existing leaderboard entry med nytt resultat. + * + *

Skapar en ny immutable entry med uppdaterat resultat.

+ * + * @param existingEntry befintlig entry + * @param newResult nytt resultat + * @return uppdaterad entry + * @throws IllegalArgumentException om någon parameter är null + */ + public LeaderboardEntry updateEntry(LeaderboardEntry existingEntry, GameResult newResult) { + if (existingEntry == null) { + throw new IllegalArgumentException("Existing entry cannot be null"); + } + if (newResult == null) { + throw new IllegalArgumentException("New result cannot be null"); + } + return existingEntry.withResult(newResult); + } + + /** + * Update action enum - vad ska göras med resultatet. + */ + public enum UpdateAction { + /** + * Lägg till nytt resultat (första gången för denna contestant). + */ + ADD, + + /** + * Uppdatera existing entry (nytt resultat är bättre). + */ + UPDATE, + + /** + * Behåll existing entry (nytt resultat är inte bättre). + */ + KEEP + } + + /** + * Update decision record - beslut om vad som ska göras. + * + * @param action vilken action som ska tas + * @param entry den entry som ska användas (ny eller uppdaterad eller existing) + * @param previousEntry tidigare entry (null vid ADD, existing vid UPDATE/KEEP) + */ + public record UpdateDecision(UpdateAction action, LeaderboardEntry entry, + LeaderboardEntry previousEntry) { + public UpdateDecision { + if (action == null) { + throw new IllegalArgumentException("Action cannot be null"); + } + if (entry == null) { + throw new IllegalArgumentException("Entry cannot be null"); + } + // previousEntry kan vara null vid ADD-action + if (action != UpdateAction.ADD && previousEntry == null) { + throw new IllegalArgumentException( + "Previous entry cannot be null for " + action + " action" + ); + } + } + + /** + * Returnerar true om ledartavlan ska uppdateras. + * + * @return true om action är ADD eller UPDATE, false om KEEP + */ + public boolean shouldUpdate() { + return action == UpdateAction.ADD || action == UpdateAction.UPDATE; + } + + /** + * Returnerar true om detta är en förbättring av existing entry. + * + * @return true om action är UPDATE + */ + public boolean isImprovement() { + return action == UpdateAction.UPDATE; + } + + /** + * Returnerar true om detta är ett nytt resultat (första gången). + * + * @return true om action är ADD + */ + public boolean isNewEntry() { + return action == UpdateAction.ADD; + } + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/dto/ContestantDTO.java b/backend-quarkus/src/main/java/se/pumfoilgame/dto/ContestantDTO.java new file mode 100644 index 0000000..4ec44fb --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/dto/ContestantDTO.java @@ -0,0 +1,26 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för Contestant. + * Används för REST API-kommunikation. + * + * OBS: Name kan vara tom sträng eftersom Go-backend returnerar "" när en contestant + * har tagits bort från systemet men fortfarande finns i leaderboard. + * Quarkus-implementationen ska hantera detta bättre. + */ +public record ContestantDTO( + @JsonProperty("email") String email, + @JsonProperty("name") String name +) { + public ContestantDTO { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + if (name == null) { + throw new IllegalArgumentException("Name cannot be null"); + } + // OBS: Name kan vara blank (tom sträng) pga Go-backend beteende + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameNotificationDTO.java b/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameNotificationDTO.java new file mode 100644 index 0000000..612e7af --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameNotificationDTO.java @@ -0,0 +1,20 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för WebSocket game notifications. + * Typer: InitGame, EndGame, AbortGame + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GameNotificationDTO( + @JsonProperty("type") String type, + @JsonProperty("userid") String userid, + @JsonProperty("name") String name, + @JsonProperty("splitTime") Double splitTime, + @JsonProperty("finishTime") Double finishTime +) { + // Helper methods removed to avoid Jackson serialization issues + // Use type.equals("InitGame") instead +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameResultDTO.java b/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameResultDTO.java new file mode 100644 index 0000000..e26422d --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/dto/GameResultDTO.java @@ -0,0 +1,24 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för GameResult. + * Innehåller resultatet från ett avslutat spel. + */ +public record GameResultDTO( + @JsonProperty("splitTime") double splitTime, + @JsonProperty("endTime") double endTime +) { + public GameResultDTO { + if (splitTime < 0) { + throw new IllegalArgumentException("Split time cannot be negative"); + } + if (endTime < 0) { + throw new IllegalArgumentException("End time cannot be negative"); + } + if (endTime < splitTime) { + throw new IllegalArgumentException("End time cannot be less than split time"); + } + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java b/backend-quarkus/src/main/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java new file mode 100644 index 0000000..97220e2 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java @@ -0,0 +1,13 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för LeaderboardEntry. + * Representerar en post i leaderboard med deltagare och resultat. + */ +public record LeaderboardEntryDTO( + @JsonProperty("contestant") ContestantDTO contestant, + @JsonProperty("result") GameResultDTO result +) { +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/dto/QueueItemDTO.java b/backend-quarkus/src/main/java/se/pumfoilgame/dto/QueueItemDTO.java new file mode 100644 index 0000000..6aa00d7 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/dto/QueueItemDTO.java @@ -0,0 +1,13 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för QueueItem. + * Representerar en deltagare i kön med timestamp. + */ +public record QueueItemDTO( + @JsonProperty("timestamp") long timestamp, + @JsonProperty("contestant") ContestantDTO contestant +) { +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/GameNotificationWebSocket.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/GameNotificationWebSocket.java new file mode 100644 index 0000000..9c6b240 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/GameNotificationWebSocket.java @@ -0,0 +1,50 @@ +package se.pumfoilgame.infrastructure.notification; + +import jakarta.websocket.*; +import jakarta.websocket.server.ServerEndpoint; + +import java.util.logging.Logger; + +/** + * WebSocket endpoint för game notifications. + * + *

Delegerar session management till WebSocketNotificationService.

+ * + *

Design Notes:

+ *
    + *
  • @ServerEndpoint("/api/v1/ws") - WebSocket endpoint path
  • + *
  • Managed by WebSocket container, not CDI
  • + *
  • Uses CDI.current() to access WebSocketNotificationService
  • + *
  • Delegates all session management to service
  • + *
+ */ +@ServerEndpoint("/api/v1/ws") +public class GameNotificationWebSocket { + + private static final Logger LOGGER = Logger.getLogger(GameNotificationWebSocket.class.getName()); + + @OnOpen + public void onOpen(Session session) { + // Get service from CDI container + WebSocketNotificationService service = jakarta.enterprise.inject.spi.CDI.current() + .select(WebSocketNotificationService.class).get(); + service.addSession(session); + } + + @OnClose + public void onClose(Session session) { + // Get service from CDI container + WebSocketNotificationService service = jakarta.enterprise.inject.spi.CDI.current() + .select(WebSocketNotificationService.class).get(); + service.removeSession(session); + } + + @OnError + public void onError(Session session, Throwable throwable) { + LOGGER.warning("WebSocket error for session " + session.getId() + ": " + throwable.getMessage()); + // Get service from CDI container + WebSocketNotificationService service = jakarta.enterprise.inject.spi.CDI.current() + .select(WebSocketNotificationService.class).get(); + service.removeSession(session); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/WebSocketNotificationService.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/WebSocketNotificationService.java new file mode 100644 index 0000000..a356b84 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/notification/WebSocketNotificationService.java @@ -0,0 +1,128 @@ +package se.pumfoilgame.infrastructure.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.websocket.Session; +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.GameResult; +import se.pumfoilgame.domain.port.NotificationService; +import se.pumfoilgame.dto.GameNotificationDTO; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * WebSocket notification service implementation. + * + *

Manages WebSocket sessions and broadcasts notifications to all connected clients.

+ * + *

Design Notes:

+ *
    + *
  • @ApplicationScoped - CDI-managed singleton
  • + *
  • Thread-safe session management med ConcurrentHashMap
  • + *
  • JSON serialization med Jackson ObjectMapper
  • + *
  • Used by GameNotificationWebSocket endpoint for session management
  • + *
+ */ +@ApplicationScoped +public class WebSocketNotificationService implements NotificationService { + + private static final Logger LOGGER = Logger.getLogger(WebSocketNotificationService.class.getName()); + + private final Set sessions = ConcurrentHashMap.newKeySet(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Registers a new WebSocket session. + * + * @param session the session to register + */ + public void addSession(Session session) { + sessions.add(session); + LOGGER.info("WebSocket client connected. Total clients: " + sessions.size()); + } + + /** + * Unregisters a WebSocket session. + * + * @param session the session to unregister + */ + public void removeSession(Session session) { + sessions.remove(session); + LOGGER.info("WebSocket client disconnected. Total clients: " + sessions.size()); + } + + @Override + public void notifyGameStarted(Contestant contestant) { + GameNotificationDTO notification = new GameNotificationDTO( + "InitGame", + contestant.getEmail(), + contestant.getName(), + null, + null + ); + broadcast(notification); + } + + @Override + public void notifyGameFinished(GameResult result) { + GameNotificationDTO notification = new GameNotificationDTO( + "EndGame", + null, + null, + result.splitTime(), + result.endTime() + ); + broadcast(notification); + } + + @Override + public void notifyGameAborted() { + GameNotificationDTO notification = new GameNotificationDTO( + "AbortGame", + null, + null, + null, + null + ); + broadcast(notification); + } + + @Override + public int getConnectedClientsCount() { + return sessions.size(); + } + + /** + * Broadcasts a notification to all connected clients. + * + * @param notification the notification to send + */ + private void broadcast(GameNotificationDTO notification) { + String json; + try { + json = objectMapper.writeValueAsString(notification); + } catch (IOException e) { + LOGGER.severe("Failed to serialize notification: " + e.getMessage()); + return; + } + + // Send to all connected sessions + for (Session session : sessions) { + if (session.isOpen()) { + try { + session.getBasicRemote().sendText(json); + } catch (IOException e) { + LOGGER.warning("Failed to send message to session " + session.getId() + ": " + e.getMessage()); + // Remove session if send fails + sessions.remove(session); + } + } else { + // Clean up closed sessions + sessions.remove(session); + } + } + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/ContestantEntity.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/ContestantEntity.java new file mode 100644 index 0000000..53b2422 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/ContestantEntity.java @@ -0,0 +1,58 @@ +package se.pumfoilgame.infrastructure.persistence.entity; + +import io.quarkus.mongodb.panache.common.MongoEntity; +import org.bson.types.ObjectId; + +/** + * MongoDB entity för Contestant (Panache). + * + *

Detta är en persistence entity i infrastructure-lagret. + * Den mappas till domain model {@link se.pumfoilgame.domain.model.Contestant} + * via {@link se.pumfoilgame.infrastructure.persistence.mapper.ContestantMapper}.

+ * + *

Collection: contestants

+ *

Identity: email (unique index)

+ * + *

Design Notes:

+ *
    + *
  • Mutable (MongoDB requirement)
  • + *
  • Public fields (Panache style)
  • + *
  • No business logic (pure data holder)
  • + *
+ */ +@MongoEntity(collection = "contestants") +public class ContestantEntity { + + /** + * MongoDB internal ID. + * Används inte som business identifier (email används istället). + */ + public ObjectId id; + + /** + * Contestant's email - unique business identifier. + */ + public String email; + + /** + * Contestant's namn. + */ + public String name; + + /** + * Default constructor (krävs av MongoDB). + */ + public ContestantEntity() { + } + + /** + * Constructor med alla fields. + * + * @param email contestant's email + * @param name contestant's namn + */ + public ContestantEntity(String email, String name) { + this.email = email; + this.name = name; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/LeaderboardEntryEntity.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/LeaderboardEntryEntity.java new file mode 100644 index 0000000..a1b5a50 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/LeaderboardEntryEntity.java @@ -0,0 +1,74 @@ +package se.pumfoilgame.infrastructure.persistence.entity; + +import io.quarkus.mongodb.panache.common.MongoEntity; +import org.bson.types.ObjectId; + +/** + * MongoDB entity för LeaderboardEntry (Panache). + * + *

Detta är en persistence entity i infrastructure-lagret. + * Den mappas till domain model {@link se.pumfoilgame.domain.model.LeaderboardEntry} + * via {@link se.pumfoilgame.infrastructure.persistence.mapper.LeaderboardEntryMapper}.

+ * + *

Collection: leaderboard

+ *

Identity: contestantEmail (unique index)

+ * + *

Design Notes:

+ *
    + *
  • Mutable (MongoDB requirement)
  • + *
  • Public fields (Panache style)
  • + *
  • No business logic (pure data holder)
  • + *
  • Denormaliserat design för query performance
  • + *
+ */ +@MongoEntity(collection = "leaderboard") +public class LeaderboardEntryEntity { + + /** + * MongoDB internal ID. + * Används inte som business identifier (contestantEmail används istället). + */ + public ObjectId id; + + /** + * Contestant's email - unique business identifier. + */ + public String contestantEmail; + + /** + * Contestant's namn (denormaliserat). + */ + public String contestantName; + + /** + * Split-tid i sekunder. + */ + public double splitTime; + + /** + * Slut-tid i sekunder. + */ + public double endTime; + + /** + * Default constructor (krävs av MongoDB). + */ + public LeaderboardEntryEntity() { + } + + /** + * Constructor med alla fields. + * + * @param contestantEmail contestant's email + * @param contestantName contestant's namn + * @param splitTime split-tid i sekunder + * @param endTime slut-tid i sekunder + */ + public LeaderboardEntryEntity(String contestantEmail, String contestantName, + double splitTime, double endTime) { + this.contestantEmail = contestantEmail; + this.contestantName = contestantName; + this.splitTime = splitTime; + this.endTime = endTime; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/QueueItemEntity.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/QueueItemEntity.java new file mode 100644 index 0000000..391c08b --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/entity/QueueItemEntity.java @@ -0,0 +1,66 @@ +package se.pumfoilgame.infrastructure.persistence.entity; + +import io.quarkus.mongodb.panache.common.MongoEntity; +import org.bson.types.ObjectId; + +/** + * MongoDB entity för QueueItem (Panache). + * + *

Detta är en persistence entity i infrastructure-lagret. + * Den mappas till domain model {@link se.pumfoilgame.domain.model.QueueItem} + * via {@link se.pumfoilgame.infrastructure.persistence.mapper.QueueItemMapper}.

+ * + *

Collection: queue

+ *

Identity: timestamp (unique index)

+ * + *

Design Notes:

+ *
    + *
  • Mutable (MongoDB requirement)
  • + *
  • Public fields (Panache style)
  • + *
  • No business logic (pure data holder)
  • + *
+ */ +@MongoEntity(collection = "queue") +public class QueueItemEntity { + + /** + * MongoDB internal ID. + * Används inte som business identifier (timestamp används istället). + */ + public ObjectId id; + + /** + * Unix timestamp (milliseconds) - unique business identifier. + * Används för FIFO-ordning i kön. + */ + public long timestamp; + + /** + * Contestant's email (reference till ContestantEntity). + */ + public String contestantEmail; + + /** + * Contestant's namn (denormaliserat för performance). + */ + public String contestantName; + + /** + * Default constructor (krävs av MongoDB). + */ + public QueueItemEntity() { + } + + /** + * Constructor med alla fields. + * + * @param timestamp när item lades till kön + * @param contestantEmail contestant's email + * @param contestantName contestant's namn + */ + public QueueItemEntity(long timestamp, String contestantEmail, String contestantName) { + this.timestamp = timestamp; + this.contestantEmail = contestantEmail; + this.contestantName = contestantName; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/ContestantMapper.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/ContestantMapper.java new file mode 100644 index 0000000..9914a37 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/ContestantMapper.java @@ -0,0 +1,56 @@ +package se.pumfoilgame.infrastructure.persistence.mapper; + +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.infrastructure.persistence.entity.ContestantEntity; + +/** + * Mapper mellan Contestant domain model och ContestantEntity. + * + *

Detta är en mapper i infrastructure-lagret som konverterar mellan + * domain modellen (immutable, business logic) och persistence entity + * (mutable, no logic).

+ * + *

Design Notes:

+ *
    + *
  • Stateless utility class med static methods
  • + *
  • Inga beroenden på andra services
  • + *
  • Null-safe (kastar exception vid null-input)
  • + *
+ */ +public final class ContestantMapper { + + /** + * Private constructor för att förhindra instantiering. + */ + private ContestantMapper() { + throw new AssertionError("Cannot instantiate utility class"); + } + + /** + * Konverterar domain model till entity. + * + * @param contestant domain model + * @return entity för MongoDB persistence + * @throws NullPointerException om contestant är null + */ + public static ContestantEntity toEntity(Contestant contestant) { + if (contestant == null) { + throw new NullPointerException("Contestant cannot be null"); + } + return new ContestantEntity(contestant.getEmail(), contestant.getName()); + } + + /** + * Konverterar entity till domain model. + * + * @param entity MongoDB entity + * @return immutable domain model + * @throws NullPointerException om entity är null + */ + public static Contestant toDomain(ContestantEntity entity) { + if (entity == null) { + throw new NullPointerException("ContestantEntity cannot be null"); + } + return new Contestant(entity.email, entity.name); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/LeaderboardEntryMapper.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/LeaderboardEntryMapper.java new file mode 100644 index 0000000..4dfe9a4 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/LeaderboardEntryMapper.java @@ -0,0 +1,65 @@ +package se.pumfoilgame.infrastructure.persistence.mapper; + +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.GameResult; +import se.pumfoilgame.domain.model.LeaderboardEntry; +import se.pumfoilgame.infrastructure.persistence.entity.LeaderboardEntryEntity; + +/** + * Mapper mellan LeaderboardEntry domain model och LeaderboardEntryEntity. + * + *

Detta är en mapper i infrastructure-lagret som konverterar mellan + * domain modellen (immutable, business logic) och persistence entity + * (mutable, no logic).

+ * + *

Design Notes:

+ *
    + *
  • Stateless utility class med static methods
  • + *
  • Hanterar denormalisering (contestant + result data inbakat)
  • + *
  • Null-safe (kastar exception vid null-input)
  • + *
+ */ +public final class LeaderboardEntryMapper { + + /** + * Private constructor för att förhindra instantiering. + */ + private LeaderboardEntryMapper() { + throw new AssertionError("Cannot instantiate utility class"); + } + + /** + * Konverterar domain model till entity. + * + * @param entry domain model + * @return entity för MongoDB persistence + * @throws NullPointerException om entry är null + */ + public static LeaderboardEntryEntity toEntity(LeaderboardEntry entry) { + if (entry == null) { + throw new NullPointerException("LeaderboardEntry cannot be null"); + } + return new LeaderboardEntryEntity( + entry.getContestant().getEmail(), + entry.getContestant().getName(), + entry.getResult().splitTime(), + entry.getResult().endTime() + ); + } + + /** + * Konverterar entity till domain model. + * + * @param entity MongoDB entity + * @return immutable domain model + * @throws NullPointerException om entity är null + */ + public static LeaderboardEntry toDomain(LeaderboardEntryEntity entity) { + if (entity == null) { + throw new NullPointerException("LeaderboardEntryEntity cannot be null"); + } + Contestant contestant = new Contestant(entity.contestantEmail, entity.contestantName); + GameResult result = new GameResult(entity.splitTime, entity.endTime); + return new LeaderboardEntry(contestant, result); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/QueueItemMapper.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/QueueItemMapper.java new file mode 100644 index 0000000..c2f01ac --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/mapper/QueueItemMapper.java @@ -0,0 +1,62 @@ +package se.pumfoilgame.infrastructure.persistence.mapper; + +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.infrastructure.persistence.entity.QueueItemEntity; + +/** + * Mapper mellan QueueItem domain model och QueueItemEntity. + * + *

Detta är en mapper i infrastructure-lagret som konverterar mellan + * domain modellen (immutable, business logic) och persistence entity + * (mutable, no logic).

+ * + *

Design Notes:

+ *
    + *
  • Stateless utility class med static methods
  • + *
  • Hanterar denormalisering (contestant data inbakat i entity)
  • + *
  • Null-safe (kastar exception vid null-input)
  • + *
+ */ +public final class QueueItemMapper { + + /** + * Private constructor för att förhindra instantiering. + */ + private QueueItemMapper() { + throw new AssertionError("Cannot instantiate utility class"); + } + + /** + * Konverterar domain model till entity. + * + * @param queueItem domain model + * @return entity för MongoDB persistence + * @throws NullPointerException om queueItem är null + */ + public static QueueItemEntity toEntity(QueueItem queueItem) { + if (queueItem == null) { + throw new NullPointerException("QueueItem cannot be null"); + } + return new QueueItemEntity( + queueItem.getTimestamp(), + queueItem.getContestant().getEmail(), + queueItem.getContestant().getName() + ); + } + + /** + * Konverterar entity till domain model. + * + * @param entity MongoDB entity + * @return immutable domain model + * @throws NullPointerException om entity är null + */ + public static QueueItem toDomain(QueueItemEntity entity) { + if (entity == null) { + throw new NullPointerException("QueueItemEntity cannot be null"); + } + Contestant contestant = new Contestant(entity.contestantEmail, entity.contestantName); + return new QueueItem(entity.timestamp, contestant); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoContestantRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoContestantRepository.java new file mode 100644 index 0000000..425e5df --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoContestantRepository.java @@ -0,0 +1,206 @@ +package se.pumfoilgame.infrastructure.persistence.repository; + +import com.mongodb.client.MongoClient; +import io.quarkus.mongodb.panache.PanacheMongoRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.Contestant; +import se.pumfoilgame.domain.port.ContestantRepository; +import se.pumfoilgame.infrastructure.persistence.entity.ContestantEntity; +import se.pumfoilgame.infrastructure.persistence.entity.QueueItemEntity; +import se.pumfoilgame.infrastructure.persistence.mapper.ContestantMapper; + +import java.util.List; +import java.util.Optional; + +/** + * MongoDB implementation av ContestantRepository med Panache. + * + *

Detta är en adapter i infrastructure-lagret som implementerar + * repository-porten från domain-lagret.

+ * + *

Responsibilities:

+ *
    + *
  • CRUD operations för contestants
  • + *
  • Queries för enqueued/not-enqueued contestants
  • + *
  • Mapping mellan domain och entity
  • + *
+ * + *

Design Notes:

+ *
    + *
  • ApplicationScoped för singleton lifecycle
  • + *
  • Använder composition för Panache operations
  • + *
  • Email är unique business identifier (ska ha unique index)
  • + *
+ */ +@ApplicationScoped +public class MongoContestantRepository implements ContestantRepository { + + /** + * Inre Panache repository class för att undvika method name collision. + */ + @ApplicationScoped + public static class ContestantEntityRepository implements PanacheMongoRepository { + } + + @Inject + ContestantEntityRepository repository; + + @Inject + MongoClient mongoClient; + + @org.eclipse.microprofile.config.inject.ConfigProperty(name = "quarkus.mongodb.database") + String databaseName; + + /** + * Hämta alla contestants. + * + * @return lista med alla contestants + */ + @Override + public List findAll() { + return repository.listAll().stream() + .map(ContestantMapper::toDomain) + .toList(); + } + + /** + * Hämta alla contestants som är i kön. + * + *

Query implementeras genom att hitta alla emails i queue-collection + * och sedan hämta motsvarande contestants.

+ * + * @return lista med contestants som är i kön + */ + @Override + public List findEnqueued() { + // Hämta alla emails från queue-collection + List enqueuedEmails = mongoClient + .getDatabase(databaseName) + .getCollection("queue", QueueItemEntity.class) + .find() + .into(new java.util.ArrayList<>()) + .stream() + .map(item -> item.contestantEmail) + .toList(); + + if (enqueuedEmails.isEmpty()) { + return List.of(); + } + + // Hämta contestants med dessa emails + return repository.list("email in ?1", enqueuedEmails).stream() + .map(ContestantMapper::toDomain) + .toList(); + } + + /** + * Hämta alla contestants som INTE är i kön. + * + *

Query implementeras genom att hitta alla emails i queue-collection + * och sedan hämta contestants som INTE har dessa emails.

+ * + * @return lista med contestants som inte är i kön + */ + @Override + public List findNotEnqueued() { + // Hämta alla emails från queue-collection + List enqueuedEmails = mongoClient + .getDatabase(databaseName) + .getCollection("queue", QueueItemEntity.class) + .find() + .into(new java.util.ArrayList<>()) + .stream() + .map(item -> item.contestantEmail) + .toList(); + + if (enqueuedEmails.isEmpty()) { + return findAll(); + } + + // Hämta contestants som INTE har dessa emails + return repository.list("email nin ?1", enqueuedEmails).stream() + .map(ContestantMapper::toDomain) + .toList(); + } + + /** + * Hitta contestant via email. + * + * @param email email att söka efter + * @return Optional med contestant om found, annars empty + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public Optional findByEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + return repository.find("email", email.trim()) + .firstResultOptional() + .map(ContestantMapper::toDomain); + } + + /** + * Spara eller uppdatera contestant. + * + *

Om contestant med samma email redan finns, uppdateras namn. + * Annars skapas ny contestant.

+ * + * @param contestant contestant att spara + * @return sparad contestant + * @throws IllegalArgumentException om contestant är null + */ + @Override + public Contestant save(Contestant contestant) { + if (contestant == null) { + throw new IllegalArgumentException("Contestant cannot be null"); + } + + // Hitta existing entity + Optional existing = repository.find("email", contestant.getEmail()).firstResultOptional(); + + ContestantEntity entity; + if (existing.isPresent()) { + // Uppdatera existing + entity = existing.get(); + entity.name = contestant.getName(); + repository.update(entity); + } else { + // Skapa ny + entity = ContestantMapper.toEntity(contestant); + repository.persist(entity); + } + + return ContestantMapper.toDomain(entity); + } + + /** + * Ta bort contestant via email. + * + * @param email email för contestant att ta bort + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public void delete(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + repository.delete("email", email.trim()); + } + + /** + * Kolla om contestant finns. + * + * @param email email att kontrollera + * @return true om contestant finns, annars false + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public boolean exists(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + return repository.count("email", email.trim()) > 0; + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoLeaderboardRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoLeaderboardRepository.java new file mode 100644 index 0000000..4576969 --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoLeaderboardRepository.java @@ -0,0 +1,182 @@ +package se.pumfoilgame.infrastructure.persistence.repository; + +import io.quarkus.mongodb.panache.PanacheMongoRepository; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.LeaderboardEntry; +import se.pumfoilgame.domain.port.LeaderboardRepository; +import se.pumfoilgame.infrastructure.persistence.entity.LeaderboardEntryEntity; +import se.pumfoilgame.infrastructure.persistence.mapper.LeaderboardEntryMapper; + +import java.util.List; +import java.util.Optional; + +/** + * MongoDB implementation av LeaderboardRepository med Panache. + * + *

Detta är en adapter i infrastructure-lagret som implementerar + * repository-porten från domain-lagret.

+ * + *

Responsibilities:

+ *
    + *
  • CRUD operations för leaderboard entries
  • + *
  • Queries sorterade på endTime (bäst först)
  • + *
  • Top-N queries
  • + *
  • Mapping mellan domain och entity
  • + *
+ * + *

Design Notes:

+ *
    + *
  • ApplicationScoped för singleton lifecycle
  • + *
  • Använder composition för Panache operations
  • + *
  • ContestantEmail är unique business identifier (ska ha unique index)
  • + *
  • Index på endTime för sorteringsperformance
  • + *
+ */ +@ApplicationScoped +public class MongoLeaderboardRepository implements LeaderboardRepository { + + /** + * Inre Panache repository class för att undvika method name collision. + */ + @ApplicationScoped + public static class LeaderboardEntryEntityRepository implements PanacheMongoRepository { + } + + @Inject + LeaderboardEntryEntityRepository repository; + + /** + * Hämta alla leaderboard entries sorterade på endTime (bäst först). + * + * @return lista med entries i rangordning + */ + @Override + public List findAllSortedByEndTime() { + return repository.listAll(Sort.by("endTime", Sort.Direction.Ascending)).stream() + .map(LeaderboardEntryMapper::toDomain) + .toList(); + } + + /** + * Hitta leaderboard entry via contestant email. + * + * @param email contestant email att söka efter + * @return Optional med entry om found, annars empty + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public Optional findByEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + return repository.find("contestantEmail", email.trim()) + .firstResultOptional() + .map(LeaderboardEntryMapper::toDomain); + } + + /** + * Hämta top N entries sorterade på endTime. + * + * @param limit antal entries att hämta + * @return lista med top N entries + * @throws IllegalArgumentException om limit är negativ eller noll + */ + @Override + public List findTopN(int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("Limit must be positive"); + } + return repository.find("", Sort.by("endTime", Sort.Direction.Ascending)) + .page(0, limit) + .list() + .stream() + .map(LeaderboardEntryMapper::toDomain) + .toList(); + } + + /** + * Spara eller uppdatera leaderboard entry. + * + *

Om entry med samma contestant email redan finns, uppdateras resultat. + * Annars skapas ny entry.

+ * + * @param entry entry att spara + * @return sparad entry + * @throws IllegalArgumentException om entry är null + */ + @Override + public LeaderboardEntry save(LeaderboardEntry entry) { + if (entry == null) { + throw new IllegalArgumentException("LeaderboardEntry cannot be null"); + } + + // Hitta existing entity + Optional existing = + repository.find("contestantEmail", entry.getContestant().getEmail()).firstResultOptional(); + + LeaderboardEntryEntity entity; + if (existing.isPresent()) { + // Uppdatera existing + entity = existing.get(); + entity.contestantName = entry.getContestant().getName(); + entity.splitTime = entry.getResult().splitTime(); + entity.endTime = entry.getResult().endTime(); + repository.update(entity); + } else { + // Skapa ny + entity = LeaderboardEntryMapper.toEntity(entry); + repository.persist(entity); + } + + return LeaderboardEntryMapper.toDomain(entity); + } + + /** + * Ta bort leaderboard entry via contestant email. + * + * @param email contestant email för entry att ta bort + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public void delete(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + repository.delete("contestantEmail", email.trim()); + } + + /** + * Ta bort alla leaderboard entries. + */ + @Override + public void deleteAll() { + repository.deleteAll(); + } + + /** + * Kolla om entry finns för contestant. + * + * @param email contestant email att kontrollera + * @return true om entry finns, annars false + * @throws IllegalArgumentException om email är null eller blank + */ + @Override + public boolean exists(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + return repository.count("contestantEmail", email.trim()) > 0; + } + + /** + * Räkna antal entries i leaderboard. + * + * @return antal entries + */ + @Override + public long count() { + return repository.count(); + } +} diff --git a/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoQueueRepository.java b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoQueueRepository.java new file mode 100644 index 0000000..184a1dd --- /dev/null +++ b/backend-quarkus/src/main/java/se/pumfoilgame/infrastructure/persistence/repository/MongoQueueRepository.java @@ -0,0 +1,157 @@ +package se.pumfoilgame.infrastructure.persistence.repository; + +import io.quarkus.mongodb.panache.PanacheMongoRepository; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import se.pumfoilgame.domain.model.QueueItem; +import se.pumfoilgame.domain.port.QueueRepository; +import se.pumfoilgame.infrastructure.persistence.entity.QueueItemEntity; +import se.pumfoilgame.infrastructure.persistence.mapper.QueueItemMapper; + +import java.util.List; +import java.util.Optional; + +/** + * MongoDB implementation av QueueRepository med Panache. + * + *

Detta är en adapter i infrastructure-lagret som implementerar + * repository-porten från domain-lagret.

+ * + *

Responsibilities:

+ *
    + *
  • CRUD operations för queue items
  • + *
  • FIFO-ordning (sorterat på timestamp)
  • + *
  • Mapping mellan domain och entity
  • + *
+ * + *

Design Notes:

+ *
    + *
  • ApplicationScoped för singleton lifecycle
  • + *
  • Använder composition för Panache operations
  • + *
  • Timestamp är unique business identifier (ska ha unique index)
  • + *
+ */ +@ApplicationScoped +public class MongoQueueRepository implements QueueRepository { + + /** + * Inre Panache repository class för att undvika method name collision. + */ + @ApplicationScoped + public static class QueueItemEntityRepository implements PanacheMongoRepository { + } + + @Inject + QueueItemEntityRepository repository; + + /** + * Hämta alla queue items sorterade på timestamp (FIFO). + * + * @return lista med queue items i kronologisk ordning + */ + @Override + public List findAll() { + return repository.listAll(Sort.by("timestamp", Sort.Direction.Ascending)).stream() + .map(QueueItemMapper::toDomain) + .toList(); + } + + /** + * Hitta queue item via timestamp. + * + * @param timestamp timestamp att söka efter + * @return Optional med queue item om found, annars empty + * @throws IllegalArgumentException om timestamp är negativ + */ + @Override + public Optional findByTimestamp(long timestamp) { + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + return repository.find("timestamp", timestamp) + .firstResultOptional() + .map(QueueItemMapper::toDomain); + } + + /** + * Hämta första queue item (äldsta timestamp). + * + * @return Optional med första item om kön inte är tom, annars empty + */ + @Override + public Optional findFirst() { + return repository.findAll(Sort.by("timestamp", Sort.Direction.Ascending)) + .firstResultOptional() + .map(QueueItemMapper::toDomain); + } + + /** + * Spara queue item. + * + *

Timestamp måste vara unique - duplicates kastas exception.

+ * + * @param queueItem item att spara + * @return sparad queue item + * @throws IllegalArgumentException om queueItem är null eller timestamp finns redan + */ + @Override + public QueueItem save(QueueItem queueItem) { + if (queueItem == null) { + throw new IllegalArgumentException("QueueItem cannot be null"); + } + + // Kontrollera att timestamp är unique + if (repository.count("timestamp", queueItem.getTimestamp()) > 0) { + throw new IllegalArgumentException( + "QueueItem with timestamp " + queueItem.getTimestamp() + " already exists" + ); + } + + QueueItemEntity entity = QueueItemMapper.toEntity(queueItem); + repository.persist(entity); + return QueueItemMapper.toDomain(entity); + } + + /** + * Ta bort queue item via timestamp. + * + * @param timestamp timestamp för item att ta bort + * @throws IllegalArgumentException om timestamp är negativ + */ + @Override + public void delete(long timestamp) { + if (timestamp < 0) { + throw new IllegalArgumentException("Timestamp cannot be negative"); + } + repository.delete("timestamp", timestamp); + } + + /** + * Ta bort alla queue items. + */ + @Override + public void deleteAll() { + repository.deleteAll(); + } + + /** + * Kolla om kön är tom. + * + * @return true om kön är tom, annars false + */ + @Override + public boolean isEmpty() { + return repository.count() == 0; + } + + /** + * Räkna antal items i kön. + * + * @return antal items + */ + @Override + public long count() { + return repository.count(); + } +} diff --git a/backend-quarkus/src/main/resources/application.properties b/backend-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..07ca5d9 --- /dev/null +++ b/backend-quarkus/src/main/resources/application.properties @@ -0,0 +1,20 @@ +# Application Configuration +quarkus.application.name=pump-foil-game + +# HTTP Configuration +quarkus.http.port=18089 +quarkus.http.cors=true + +# MongoDB Configuration +quarkus.mongodb.connection-string=mongodb://localhost:27017 +quarkus.mongodb.database=foildb + +# OpenAPI / Swagger UI +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.path=/swagger-ui +quarkus.smallrye-openapi.path=/swagger + +# Logging +quarkus.log.level=INFO +quarkus.log.category."se.pumfoilgame".level=DEBUG + diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/api/ContestantApiTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/api/ContestantApiTest.java new file mode 100644 index 0000000..86b965d --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/api/ContestantApiTest.java @@ -0,0 +1,207 @@ +package se.pumfoilgame.api; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.QueueItemDTO; +import se.pumfoilgame.test.GoBackendTestBase; +import se.pumfoilgame.test.MongoDBResource; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Tester för Contestant API endpoints. + * Dessa tester körs mot Go-backenden för att verifiera befintligt beteende. + */ +@QuarkusTest +@DisplayName("Contestant API Tests (mot Go-backend)") +class ContestantApiTest extends GoBackendTestBase { + + @Test + @DisplayName("GET /contestants - Go-backend returnerar 400 när inga contestants finns (bugg)") + void should_return400_when_noContestants() { + // OBS: Detta är ett känt beteende i Go-backend där nil returneras istället för tom array + // När vi implementerar Quarkus ska detta fixas till att returnera 200 med tom array + given() + .spec(requestSpec) + .when() + .get("/contestants") + .then() + .statusCode(400) + .body("error", equalTo("invalid filter")); + } + + @Test + @DisplayName("POST /contestants - ska skapa ny contestant och lägga i kö") + void should_createContestantAndEnqueue_when_validData() { + ContestantDTO contestant = new ContestantDTO("test@example.com", "Test User"); + + QueueItemDTO queueItem = given() + .spec(requestSpec) + .body(contestant) + .when() + .post("/contestants") + .then() + .statusCode(200) + .body("contestant.email", equalTo("test@example.com")) + .body("contestant.name", equalTo("Test User")) + .body("timestamp", notNullValue()) + .extract() + .as(QueueItemDTO.class); + + assertThat(queueItem.timestamp()).isPositive(); + assertThat(queueItem.contestant().email()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("POST /contestants - ska returnera befintlig kö-post vid duplicerad email") + void should_returnExistingQueueItem_when_duplicateEmail() { + ContestantDTO contestant = new ContestantDTO("duplicate@example.com", "Duplicate User"); + + // Skapa första gången + QueueItemDTO firstQueueItem = given() + .spec(requestSpec) + .body(contestant) + .when() + .post("/contestants") + .then() + .statusCode(200) + .extract() + .as(QueueItemDTO.class); + + // Försök skapa igen med samma email + QueueItemDTO secondQueueItem = given() + .spec(requestSpec) + .body(new ContestantDTO("duplicate@example.com", "Different Name")) + .when() + .post("/contestants") + .then() + .statusCode(200) + .extract() + .as(QueueItemDTO.class); + + // Ska returnera samma timestamp (sista i kön) + assertThat(secondQueueItem.timestamp()).isEqualTo(firstQueueItem.timestamp()); + } + + @Test + @DisplayName("GET /contestants?filter=ALL - ska returnera alla contestants") + void should_returnAllContestants_when_filterIsAll() { + // Lägg till två contestants + given().spec(requestSpec).body(new ContestantDTO("user1@example.com", "User One")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("user2@example.com", "User Two")) + .post("/contestants"); + + given() + .spec(requestSpec) + .queryParam("filter", "ALL") + .when() + .get("/contestants") + .then() + .statusCode(200) + .body("$", hasSize(2)) + .body("email", hasItems("user1@example.com", "user2@example.com")); + } + + @Test + @DisplayName("GET /contestants?filter=ENQUEUED - ska returnera endast köade contestants") + void should_returnEnqueuedContestants_when_filterIsEnqueued() { + // Lägg till två contestants (båda hamnar i kö) + given().spec(requestSpec).body(new ContestantDTO("queued1@example.com", "Queued One")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("queued2@example.com", "Queued Two")) + .post("/contestants"); + + given() + .spec(requestSpec) + .queryParam("filter", "ENQUEUED") + .when() + .get("/contestants") + .then() + .statusCode(200) + .body("$", hasSize(2)); + } + + @Test + @DisplayName("GET /contestants?filter=NOT_ENQUEUED - ska returnera endast icke-köade contestants") + void should_returnNotEnqueuedContestants_when_filterIsNotEnqueued() { + // Lägg till och starta ett spel (tar bort från kö) + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + + // Starta spel (tar bort från kö) + given().spec(requestSpec).post("/game-start"); + + // Lägg till en ny contestant (finns i kö) + given().spec(requestSpec).body(new ContestantDTO("queued@example.com", "Queued")) + .post("/contestants"); + + given() + .spec(requestSpec) + .queryParam("filter", "NOT_ENQUEUED") + .when() + .get("/contestants") + .then() + .statusCode(200) + .body("$", hasSize(1)) + .body("email", hasItem("player@example.com")); + } + + @Test + @DisplayName("DELETE /contestants/{email} - ska ta bort contestant och kö-post") + void should_deleteContestantAndQueueItem_when_validEmail() { + // Skapa contestant + given().spec(requestSpec).body(new ContestantDTO("delete@example.com", "Delete Me")) + .post("/contestants"); + + // Verifiera att den finns + given().spec(requestSpec).get("/contestants") + .then().body("$", hasSize(1)); + + // Ta bort + given() + .spec(requestSpec) + .when() + .delete("/contestants/delete@example.com") + .then() + .statusCode(200); + + // Verifiera att den är borta (Go returnerar 400 när inga finns) + given().spec(requestSpec).get("/contestants") + .then().statusCode(400); + + // Verifiera att kö-posten också är borta + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(0)); + } + + @Test + @DisplayName("DELETE /contestants/{email} - ska returnera 500 för icke-existerande email") + void should_return500_when_deletingNonExistentEmail() { + given() + .spec(requestSpec) + .when() + .delete("/contestants/nonexistent@example.com") + .then() + .statusCode(500); + } + + @Test + @DisplayName("GET /contestants?filter=INVALID - ska returnera 400 för ogiltig filter") + void should_return400_when_invalidFilter() { + given() + .spec(requestSpec) + .queryParam("filter", "INVALID_FILTER") + .when() + .get("/contestants") + .then() + .statusCode(400); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/api/GameApiTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/api/GameApiTest.java new file mode 100644 index 0000000..6090210 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/api/GameApiTest.java @@ -0,0 +1,261 @@ +package se.pumfoilgame.api; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.GameResultDTO; +import se.pumfoilgame.dto.LeaderboardEntryDTO; +import se.pumfoilgame.dto.QueueItemDTO; +import se.pumfoilgame.test.GoBackendTestBase; +import se.pumfoilgame.test.MongoDBResource; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Tester för Game Lifecycle API endpoints (start, finish, abort). + * Dessa tester körs mot Go-backenden för att verifiera befintligt beteende. + */ +@QuarkusTest +@DisplayName("Game API Tests (mot Go-backend)") +class GameApiTest extends GoBackendTestBase { + + @Test + @DisplayName("POST /game-start - ska starta spel med första i kön") + void should_startGameWithFirstInQueue_when_noTimestampProvided() { + // Lägg till två contestants + given().spec(requestSpec).body(new ContestantDTO("player1@example.com", "Player 1")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants"); + + // Starta spel utan timestamp (tar första i kön) + QueueItemDTO startedGame = given() + .spec(requestSpec) + .when() + .post("/game-start") + .then() + .statusCode(200) + .body("contestant.email", equalTo("player1@example.com")) + .body("contestant.name", equalTo("Player 1")) + .body("timestamp", notNullValue()) + .extract() + .as(QueueItemDTO.class); + + assertThat(startedGame.contestant().email()).isEqualTo("player1@example.com"); + + // Kön ska nu endast innehålla player2 + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(1)) + .body("contestant.email[0]", equalTo("player2@example.com")); + } + + @Test + @DisplayName("POST /game-start?timestamp=X - ska starta spel med specifik queue item") + void should_startGameWithSpecificQueueItem_when_timestampProvided() { + // Lägg till tre contestants + given().spec(requestSpec).body(new ContestantDTO("player1@example.com", "Player 1")) + .post("/contestants"); + + QueueItemDTO player2Queue = given().spec(requestSpec) + .body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants").then().extract().as(QueueItemDTO.class); + + given().spec(requestSpec).body(new ContestantDTO("player3@example.com", "Player 3")) + .post("/contestants"); + + // Starta spel med player2 (mitten) + QueueItemDTO startedGame = given() + .spec(requestSpec) + .queryParam("timestamp", player2Queue.timestamp()) + .when() + .post("/game-start") + .then() + .statusCode(200) + .body("contestant.email", equalTo("player2@example.com")) + .extract() + .as(QueueItemDTO.class); + + // Kön ska nu innehålla player1 och player3 + QueueItemDTO[] queue = given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(2)) + .extract().as(QueueItemDTO[].class); + + assertThat(queue).extracting(q -> q.contestant().email()) + .containsExactly("player1@example.com", "player3@example.com"); + } + + @Test + @DisplayName("POST /game-start - ska returnera 500 när kön är tom") + void should_return500_when_queueIsEmpty() { + given() + .spec(requestSpec) + .when() + .post("/game-start") + .then() + .statusCode(500); + } + + @Test + @DisplayName("POST /game-start - ska returnera 500 när spel redan pågår") + void should_return500_when_gameAlreadyInProgress() { + // Lägg till contestant och starta spel + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Lägg till ytterligare en contestant + given().spec(requestSpec).body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants"); + + // Försök starta nytt spel (ska misslyckas) + given() + .spec(requestSpec) + .when() + .post("/game-start") + .then() + .statusCode(500); + } + + @Test + @DisplayName("POST /game-start?timestamp=X - ska returnera 500 för icke-existerande timestamp") + void should_return500_when_timestampNotFoundInQueue() { + // Lägg till contestant + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + + // Försök starta med icke-existerande timestamp + given() + .spec(requestSpec) + .queryParam("timestamp", 999999999L) + .when() + .post("/game-start") + .then() + .statusCode(500); + } + + @Test + @DisplayName("POST /game-finish - ska avsluta spel och returnera leaderboard") + void should_finishGameAndReturnLeaderboard_when_gameInProgress() { + // Lägg till contestant och starta spel + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Avsluta spel med resultat + GameResultDTO result = new GameResultDTO(12.5, 25.3); + + LeaderboardEntryDTO[] leaderboard = given() + .spec(requestSpec) + .body(result) + .when() + .post("/game-finish") + .then() + .statusCode(200) + .body("$", hasSize(1)) + .body("contestant.email[0]", equalTo("player@example.com")) + .body("result.splitTime[0]", equalTo(12.5f)) + .body("result.endTime[0]", equalTo(25.3f)) + .extract() + .as(LeaderboardEntryDTO[].class); + + assertThat(leaderboard[0].contestant().email()).isEqualTo("player@example.com"); + assertThat(leaderboard[0].result().splitTime()).isEqualTo(12.5); + assertThat(leaderboard[0].result().endTime()).isEqualTo(25.3); + } + + @Test + @DisplayName("POST /game-finish - ska returnera 500 när inget spel pågår") + void should_return500_when_noGameInProgress() { + GameResultDTO result = new GameResultDTO(12.5, 25.3); + + given() + .spec(requestSpec) + .body(result) + .when() + .post("/game-finish") + .then() + .statusCode(500); + } + + @Test + @DisplayName("POST /game-abort - ska avbryta pågående spel") + void should_abortGame_when_gameInProgress() { + // Lägg till contestant och starta spel + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Avbryt spel + given() + .spec(requestSpec) + .when() + .post("/game-abort") + .then() + .statusCode(200) + .body(equalTo("\"aborted\"")); + + // Ska nu kunna starta nytt spel (verifierar att gameState är reset) + given().spec(requestSpec).body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants"); + + given().spec(requestSpec).post("/game-start") + .then().statusCode(200); + } + + @Test + @DisplayName("POST /game-abort - ska returnera 200 även om inget spel pågår") + void should_return200_when_abortingWithNoGameInProgress() { + given() + .spec(requestSpec) + .when() + .post("/game-abort") + .then() + .statusCode(200); + } + + @Test + @DisplayName("Komplett spelflöde - från kö till leaderboard") + void should_completeFullGameFlow_when_playingMultipleGames() { + // Lägg till tre spelare + given().spec(requestSpec).body(new ContestantDTO("player1@example.com", "Player 1")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("player3@example.com", "Player 3")) + .post("/contestants"); + + // Spel 1: Player 1 + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(15.0, 30.0)) + .post("/game-finish"); + + // Spel 2: Player 2 (bättre tid) + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(12.0, 25.0)) + .post("/game-finish"); + + // Spel 3: Player 3 (sämre tid) + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(18.0, 35.0)) + .post("/game-finish"); + + // Verifiera leaderboard (sorterad på endTime) + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec).get("/leaderboard") + .then().body("$", hasSize(3)) + .extract().as(LeaderboardEntryDTO[].class); + + // Ska vara sorterad på endTime (lägst först) + assertThat(leaderboard[0].contestant().email()).isEqualTo("player2@example.com"); + assertThat(leaderboard[0].result().endTime()).isEqualTo(25.0); + + assertThat(leaderboard[1].contestant().email()).isEqualTo("player1@example.com"); + assertThat(leaderboard[1].result().endTime()).isEqualTo(30.0); + + assertThat(leaderboard[2].contestant().email()).isEqualTo("player3@example.com"); + assertThat(leaderboard[2].result().endTime()).isEqualTo(35.0); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/api/LeaderboardApiTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/api/LeaderboardApiTest.java new file mode 100644 index 0000000..a64ca44 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/api/LeaderboardApiTest.java @@ -0,0 +1,186 @@ +package se.pumfoilgame.api; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.GameResultDTO; +import se.pumfoilgame.dto.LeaderboardEntryDTO; +import se.pumfoilgame.test.GoBackendTestBase; +import se.pumfoilgame.test.MongoDBResource; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Tester för Leaderboard API endpoints. + * Dessa tester körs mot Go-backenden för att verifiera befintligt beteende. + */ +@QuarkusTest +@DisplayName("Leaderboard API Tests (mot Go-backend)") +class LeaderboardApiTest extends GoBackendTestBase { + + @Test + @DisplayName("GET /leaderboard - ska returnera tom lista när ingen har spelat") + void should_returnEmptyLeaderboard_when_noGamesPlayed() { + given() + .spec(requestSpec) + .when() + .get("/leaderboard") + .then() + .statusCode(200) + .body("$", hasSize(0)); + } + + @Test + @DisplayName("GET /leaderboard - ska returnera sorterad lista på endTime") + void should_returnSortedLeaderboard_when_multipleGamesCompleted() { + // Spela tre spel med olika tider + playGame("player1@example.com", "Player 1", 15.0, 30.0); + playGame("player2@example.com", "Player 2", 12.0, 25.0); + playGame("player3@example.com", "Player 3", 18.0, 35.0); + + LeaderboardEntryDTO[] leaderboard = given() + .spec(requestSpec) + .when() + .get("/leaderboard") + .then() + .statusCode(200) + .body("$", hasSize(3)) + .extract() + .as(LeaderboardEntryDTO[].class); + + // Verifiera sortering (lägst endTime först) + assertThat(leaderboard[0].result().endTime()).isEqualTo(25.0); + assertThat(leaderboard[1].result().endTime()).isEqualTo(30.0); + assertThat(leaderboard[2].result().endTime()).isEqualTo(35.0); + } + + @Test + @DisplayName("GET /leaderboard - ska uppdatera befintlig post om spelaren spelar igen med bättre tid") + void should_updateLeaderboardEntry_when_playerImprovesScore() { + // Första försöket + playGame("player@example.com", "Player", 20.0, 40.0); + + LeaderboardEntryDTO[] leaderboard1 = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + assertThat(leaderboard1).hasSize(1); + assertThat(leaderboard1[0].result().endTime()).isEqualTo(40.0); + + // Andra försöket med bättre tid + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(15.0, 30.0)) + .post("/game-finish"); + + LeaderboardEntryDTO[] leaderboard2 = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + + // Ska fortfarande vara en post, men med bättre tid + assertThat(leaderboard2).hasSize(1); + assertThat(leaderboard2[0].contestant().email()).isEqualTo("player@example.com"); + assertThat(leaderboard2[0].result().endTime()).isEqualTo(30.0); + assertThat(leaderboard2[0].result().splitTime()).isEqualTo(15.0); + } + + @Test + @DisplayName("GET /leaderboard - ska INTE uppdatera om spelaren spelar igen med sämre tid") + void should_notUpdateLeaderboardEntry_when_playerScoreIsWorse() { + // Första försöket (bra tid) + playGame("player@example.com", "Player", 15.0, 30.0); + + // Andra försöket med sämre tid + given().spec(requestSpec).body(new ContestantDTO("player@example.com", "Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(20.0, 40.0)) + .post("/game-finish"); + + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + + // Ska behålla den bättre tiden + assertThat(leaderboard).hasSize(1); + assertThat(leaderboard[0].result().endTime()).isEqualTo(30.0); + assertThat(leaderboard[0].result().splitTime()).isEqualTo(15.0); + } + + @Test + @DisplayName("GET /leaderboard - ska inkludera både splitTime och endTime") + void should_includeAllResultFields_when_retrievingLeaderboard() { + playGame("player@example.com", "Player", 12.5, 25.8); + + given() + .spec(requestSpec) + .when() + .get("/leaderboard") + .then() + .statusCode(200) + .body("$", hasSize(1)) + .body("contestant.email[0]", equalTo("player@example.com")) + .body("contestant.name[0]", equalTo("Player")) + .body("result.splitTime[0]", equalTo(12.5f)) + .body("result.endTime[0]", equalTo(25.8f)); + } + + @Test + @DisplayName("GET /leaderboard - ska hantera många spelare korrekt") + void should_handleManyPlayers_when_multipleGamesCompleted() { + // Spela 10 spel + for (int i = 1; i <= 10; i++) { + double endTime = 20.0 + i; + playGame("player" + i + "@example.com", "Player " + i, endTime / 2, endTime); + } + + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec).get("/leaderboard") + .then().body("$", hasSize(10)) + .extract().as(LeaderboardEntryDTO[].class); + + // Verifiera att sorteringen stämmer + for (int i = 0; i < leaderboard.length - 1; i++) { + assertThat(leaderboard[i].result().endTime()) + .isLessThan(leaderboard[i + 1].result().endTime()); + } + } + + @Test + @DisplayName("GET /leaderboard - ska returnera samma resultat vid upprepade anrop") + void should_returnConsistentResults_when_calledMultipleTimes() { + playGame("player1@example.com", "Player 1", 15.0, 30.0); + playGame("player2@example.com", "Player 2", 12.0, 25.0); + + LeaderboardEntryDTO[] leaderboard1 = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + + LeaderboardEntryDTO[] leaderboard2 = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + + LeaderboardEntryDTO[] leaderboard3 = given().spec(requestSpec).get("/leaderboard") + .then().extract().as(LeaderboardEntryDTO[].class); + + assertThat(leaderboard1).hasSize(2); + assertThat(leaderboard2).hasSize(2); + assertThat(leaderboard3).hasSize(2); + + // Verifiera att ordningen är densamma + for (int i = 0; i < 2; i++) { + assertThat(leaderboard1[i].contestant().email()) + .isEqualTo(leaderboard2[i].contestant().email()) + .isEqualTo(leaderboard3[i].contestant().email()); + } + } + + /** + * Hjälpmetod för att spela ett komplett spel. + */ + private void playGame(String email, String name, double splitTime, double endTime) { + given().spec(requestSpec).body(new ContestantDTO(email, name)) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec).body(new GameResultDTO(splitTime, endTime)) + .post("/game-finish"); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/api/QueueApiTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/api/QueueApiTest.java new file mode 100644 index 0000000..7670e91 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/api/QueueApiTest.java @@ -0,0 +1,167 @@ +package se.pumfoilgame.api; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.QueueItemDTO; +import se.pumfoilgame.test.GoBackendTestBase; +import se.pumfoilgame.test.MongoDBResource; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Tester för Queue API endpoints. + * Dessa tester körs mot Go-backenden för att verifiera befintligt beteende. + */ +@QuarkusTest +@DisplayName("Queue API Tests (mot Go-backend)") +class QueueApiTest extends GoBackendTestBase { + + @Test + @DisplayName("GET /queue - ska returnera tom lista när kön är tom") + void should_returnEmptyQueue_when_noItemsInQueue() { + given() + .spec(requestSpec) + .when() + .get("/queue") + .then() + .statusCode(200) + .body("$", hasSize(0)); + } + + @Test + @DisplayName("GET /queue - ska returnera köade contestants i timestamp-ordning") + void should_returnQueueInTimestampOrder_when_multipleItems() { + // Lägg till tre contestants + given().spec(requestSpec).body(new ContestantDTO("first@example.com", "First")) + .post("/contestants"); + + // Vänta lite för att få olika timestamps + try { Thread.sleep(10); } catch (InterruptedException e) {} + + given().spec(requestSpec).body(new ContestantDTO("second@example.com", "Second")) + .post("/contestants"); + + try { Thread.sleep(10); } catch (InterruptedException e) {} + + given().spec(requestSpec).body(new ContestantDTO("third@example.com", "Third")) + .post("/contestants"); + + QueueItemDTO[] queue = given() + .spec(requestSpec) + .when() + .get("/queue") + .then() + .statusCode(200) + .body("$", hasSize(3)) + .extract() + .as(QueueItemDTO[].class); + + // Verifiera ordning baserat på timestamp + assertThat(queue[0].timestamp()).isLessThan(queue[1].timestamp()); + assertThat(queue[1].timestamp()).isLessThan(queue[2].timestamp()); + + // Verifiera att email är i rätt ordning + assertThat(queue[0].contestant().email()).isEqualTo("first@example.com"); + assertThat(queue[1].contestant().email()).isEqualTo("second@example.com"); + assertThat(queue[2].contestant().email()).isEqualTo("third@example.com"); + } + + @Test + @DisplayName("DELETE /queue/{timestamp} - ska ta bort specifik queue item") + void should_removeQueueItem_when_validTimestamp() { + // Lägg till contestant + QueueItemDTO queueItem = given().spec(requestSpec) + .body(new ContestantDTO("remove@example.com", "Remove Me")) + .post("/contestants") + .then().extract().as(QueueItemDTO.class); + + // Verifiera att den finns i kön + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(1)); + + // Ta bort från kön + given() + .spec(requestSpec) + .when() + .delete("/queue/" + queueItem.timestamp()) + .then() + .statusCode(200); + + // Verifiera att kön är tom + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(0)); + + // Men contestant ska fortfarande finnas + given().spec(requestSpec).get("/contestants") + .then().body("$", hasSize(1)); + } + + @Test + @DisplayName("DELETE /queue/{timestamp} - Go returnerar 200 även för icke-existerande timestamp") + void should_return200_when_deletingNonExistentTimestamp() { + // OBS: Go-backend returnerar 200 även om timestamp inte finns + // Quarkus ska returnera 404 för bättre semantik + given() + .spec(requestSpec) + .when() + .delete("/queue/999999999") + .then() + .statusCode(200); + } + + @Test + @DisplayName("GET /queue - ska inte innehålla contestant som startat spel") + void should_notContainStartedGame_when_gameIsInProgress() { + // Lägg till två contestants + given().spec(requestSpec).body(new ContestantDTO("player1@example.com", "Player 1")) + .post("/contestants"); + given().spec(requestSpec).body(new ContestantDTO("player2@example.com", "Player 2")) + .post("/contestants"); + + // Starta spel (tar bort första från kön) + given().spec(requestSpec).post("/game-start"); + + // Kön ska nu endast innehålla en person + given() + .spec(requestSpec) + .when() + .get("/queue") + .then() + .statusCode(200) + .body("$", hasSize(1)) + .body("contestant.email[0]", equalTo("player2@example.com")); + } + + @Test + @DisplayName("DELETE /queue/{timestamp} - ska ta bort endast specifik post vid flera i kö") + void should_removeOnlySpecificItem_when_multipleInQueue() { + // Lägg till tre contestants + QueueItemDTO item1 = given().spec(requestSpec) + .body(new ContestantDTO("user1@example.com", "User 1")) + .post("/contestants").then().extract().as(QueueItemDTO.class); + + QueueItemDTO item2 = given().spec(requestSpec) + .body(new ContestantDTO("user2@example.com", "User 2")) + .post("/contestants").then().extract().as(QueueItemDTO.class); + + QueueItemDTO item3 = given().spec(requestSpec) + .body(new ContestantDTO("user3@example.com", "User 3")) + .post("/contestants").then().extract().as(QueueItemDTO.class); + + // Ta bort mitten-posten + given().spec(requestSpec).delete("/queue/" + item2.timestamp()); + + // Verifiera att endast item2 är borta + QueueItemDTO[] queue = given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(2)) + .extract().as(QueueItemDTO[].class); + + assertThat(queue).extracting(q -> q.contestant().email()) + .containsExactly("user1@example.com", "user3@example.com"); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/ContestantTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/ContestantTest.java new file mode 100644 index 0000000..85748a8 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/ContestantTest.java @@ -0,0 +1,191 @@ +package se.pumfoilgame.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests för Contestant entity. + */ +@DisplayName("Contestant Entity Tests") +class ContestantTest { + + @Test + @DisplayName("Ska skapa Contestant med giltiga värden") + void should_createContestant_when_validValues() { + // Given + String email = "test@example.com"; + String name = "Test User"; + + // When + Contestant contestant = new Contestant(email, name); + + // Then + assertThat(contestant.getEmail()).isEqualTo(email); + assertThat(contestant.getName()).isEqualTo(name); + } + + @Test + @DisplayName("Ska trimma email och name vid skapande") + void should_trimValues_when_creatingContestant() { + // Given + String email = " test@example.com "; + String name = " Test User "; + + // When + Contestant contestant = new Contestant(email, name); + + // Then + assertThat(contestant.getEmail()).isEqualTo("test@example.com"); + assertThat(contestant.getName()).isEqualTo("Test User"); + } + + @Test + @DisplayName("Ska kasta exception när email är null") + void should_throwException_when_emailIsNull() { + assertThatThrownBy(() -> new Contestant(null, "Test User")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Email cannot be null or blank"); + } + + @Test + @DisplayName("Ska kasta exception när email är blank") + void should_throwException_when_emailIsBlank() { + assertThatThrownBy(() -> new Contestant(" ", "Test User")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Email cannot be null or blank"); + } + + @Test + @DisplayName("Ska kasta exception när email är tom sträng") + void should_throwException_when_emailIsEmpty() { + assertThatThrownBy(() -> new Contestant("", "Test User")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Email cannot be null or blank"); + } + + @Test + @DisplayName("Ska kasta exception när name är null") + void should_throwException_when_nameIsNull() { + assertThatThrownBy(() -> new Contestant("test@example.com", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Name cannot be null or blank"); + } + + @Test + @DisplayName("Ska kasta exception när name är blank") + void should_throwException_when_nameIsBlank() { + assertThatThrownBy(() -> new Contestant("test@example.com", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Name cannot be null or blank"); + } + + @Test + @DisplayName("Ska kasta exception när name är tom sträng") + void should_throwException_when_nameIsEmpty() { + assertThatThrownBy(() -> new Contestant("test@example.com", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Name cannot be null or blank"); + } + + @Test + @DisplayName("Två Contestants med samma email ska vara lika (identity equality)") + void should_beEqual_when_sameEmail() { + // Given + Contestant contestant1 = new Contestant("test@example.com", "Name One"); + Contestant contestant2 = new Contestant("test@example.com", "Name Two"); + + // Then + assertThat(contestant1).isEqualTo(contestant2); + assertThat(contestant1.hashCode()).isEqualTo(contestant2.hashCode()); + } + + @Test + @DisplayName("Två Contestants med olika email ska inte vara lika") + void should_notBeEqual_when_differentEmail() { + // Given + Contestant contestant1 = new Contestant("test1@example.com", "Same Name"); + Contestant contestant2 = new Contestant("test2@example.com", "Same Name"); + + // Then + assertThat(contestant1).isNotEqualTo(contestant2); + } + + @Test + @DisplayName("Contestant ska vara lika med sig själv") + void should_beEqual_when_sameInstance() { + // Given + Contestant contestant = new Contestant("test@example.com", "Test User"); + + // Then + assertThat(contestant).isEqualTo(contestant); + } + + @Test + @DisplayName("Contestant ska inte vara lika med null") + void should_notBeEqual_when_null() { + // Given + Contestant contestant = new Contestant("test@example.com", "Test User"); + + // Then + assertThat(contestant).isNotEqualTo(null); + } + + @Test + @DisplayName("Contestant ska inte vara lika med objekt av annan typ") + void should_notBeEqual_when_differentType() { + // Given + Contestant contestant = new Contestant("test@example.com", "Test User"); + String notAContestant = "test@example.com"; + + // Then + assertThat(contestant).isNotEqualTo(notAContestant); + } + + @Test + @DisplayName("withName ska skapa ny instans med uppdaterat namn") + void should_createNewInstance_when_withName() { + // Given + Contestant original = new Contestant("test@example.com", "Original Name"); + + // When + Contestant updated = original.withName("Updated Name"); + + // Then + assertThat(updated.getEmail()).isEqualTo(original.getEmail()); + assertThat(updated.getName()).isEqualTo("Updated Name"); + assertThat(original.getName()).isEqualTo("Original Name"); // Original oförändrad + assertThat(updated).isNotSameAs(original); // Olika instanser + assertThat(updated).isEqualTo(original); // Men samma identity (email) + } + + @Test + @DisplayName("withName ska kasta exception för ogiltigt namn") + void should_throwException_when_withNameInvalid() { + // Given + Contestant contestant = new Contestant("test@example.com", "Original Name"); + + // Then + assertThatThrownBy(() -> contestant.withName("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Name cannot be null or blank"); + } + + @Test + @DisplayName("toString ska returnera användbar representation") + void should_returnUsefulString_when_toString() { + // Given + Contestant contestant = new Contestant("test@example.com", "Test User"); + + // When + String result = contestant.toString(); + + // Then + assertThat(result) + .contains("Contestant") + .contains("test@example.com") + .contains("Test User"); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameResultTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameResultTest.java new file mode 100644 index 0000000..65f49bf --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameResultTest.java @@ -0,0 +1,211 @@ +package se.pumfoilgame.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests för GameResult value object. + */ +@DisplayName("GameResult Value Object Tests") +class GameResultTest { + + @Test + @DisplayName("Ska skapa GameResult med giltiga värden") + void should_createGameResult_when_validValues() { + // When + GameResult result = new GameResult(15.5, 32.8); + + // Then + assertThat(result.splitTime()).isEqualTo(15.5); + assertThat(result.endTime()).isEqualTo(32.8); + } + + @Test + @DisplayName("Ska skapa GameResult när splitTime == endTime") + void should_createGameResult_when_splitTimeEqualsEndTime() { + // When + GameResult result = new GameResult(10.0, 10.0); + + // Then + assertThat(result.splitTime()).isEqualTo(10.0); + assertThat(result.endTime()).isEqualTo(10.0); + } + + @Test + @DisplayName("Ska skapa GameResult med noll-värden") + void should_createGameResult_when_zeroValues() { + // When + GameResult result = new GameResult(0.0, 0.0); + + // Then + assertThat(result.splitTime()).isEqualTo(0.0); + assertThat(result.endTime()).isEqualTo(0.0); + } + + @Test + @DisplayName("Ska kasta exception när endTime < splitTime") + void should_throwException_when_endTimeLessThanSplitTime() { + assertThatThrownBy(() -> new GameResult(20.0, 10.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End time") + .hasMessageContaining("cannot be less than split time"); + } + + @Test + @DisplayName("Ska kasta exception när splitTime är negativ") + void should_throwException_when_splitTimeIsNegative() { + assertThatThrownBy(() -> new GameResult(-5.0, 10.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Split time cannot be negative"); + } + + @Test + @DisplayName("Ska kasta exception när endTime är negativ") + void should_throwException_when_endTimeIsNegative() { + assertThatThrownBy(() -> new GameResult(5.0, -10.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End time cannot be negative"); + } + + @Test + @DisplayName("Ska kasta exception när båda tider är negativa") + void should_throwException_when_bothTimesNegative() { + assertThatThrownBy(() -> new GameResult(-5.0, -10.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + @DisplayName("isFasterThan ska returnera true när detta resultat är snabbare") + void should_returnTrue_when_thisFaster() { + // Given + GameResult fasterResult = new GameResult(10.0, 20.0); + GameResult slowerResult = new GameResult(10.0, 30.0); + + // Then + assertThat(fasterResult.isFasterThan(slowerResult)).isTrue(); + } + + @Test + @DisplayName("isFasterThan ska returnera false när detta resultat är långsammare") + void should_returnFalse_when_thisSlower() { + // Given + GameResult slowerResult = new GameResult(10.0, 30.0); + GameResult fasterResult = new GameResult(10.0, 20.0); + + // Then + assertThat(slowerResult.isFasterThan(fasterResult)).isFalse(); + } + + @Test + @DisplayName("isFasterThan ska returnera false när tiderna är lika") + void should_returnFalse_when_timesEqual() { + // Given + GameResult result1 = new GameResult(10.0, 20.0); + GameResult result2 = new GameResult(15.0, 20.0); + + // Then + assertThat(result1.isFasterThan(result2)).isFalse(); + assertThat(result2.isFasterThan(result1)).isFalse(); + } + + @Test + @DisplayName("isFasterThan ska kasta exception när other är null") + void should_throwException_when_comparingWithNull() { + // Given + GameResult result = new GameResult(10.0, 20.0); + + // Then + assertThatThrownBy(() -> result.isFasterThan(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Cannot compare with null"); + } + + @Test + @DisplayName("getSecondHalfTime ska returnera korrekt värde") + void should_returnCorrectSecondHalfTime() { + // Given + GameResult result = new GameResult(15.5, 32.8); + + // When + double secondHalf = result.getSecondHalfTime(); + + // Then + assertThat(secondHalf).isCloseTo(17.3, org.assertj.core.data.Offset.offset(0.001)); + } + + @Test + @DisplayName("getSecondHalfTime ska returnera 0 när splitTime == endTime") + void should_returnZero_when_splitEqualsEnd() { + // Given + GameResult result = new GameResult(20.0, 20.0); + + // When + double secondHalf = result.getSecondHalfTime(); + + // Then + assertThat(secondHalf).isEqualTo(0.0); + } + + @Test + @DisplayName("isValid ska returnera true för giltigt resultat") + void should_returnTrue_when_resultIsValid() { + // Given + GameResult result = new GameResult(15.5, 32.8); + + // Then + assertThat(result.isValid()).isTrue(); + } + + @Test + @DisplayName("Två GameResults med samma värden ska vara lika (value equality)") + void should_beEqual_when_sameValues() { + // Given + GameResult result1 = new GameResult(15.5, 32.8); + GameResult result2 = new GameResult(15.5, 32.8); + + // Then + assertThat(result1).isEqualTo(result2); + assertThat(result1.hashCode()).isEqualTo(result2.hashCode()); + } + + @Test + @DisplayName("Två GameResults med olika värden ska inte vara lika") + void should_notBeEqual_when_differentValues() { + // Given + GameResult result1 = new GameResult(15.5, 32.8); + GameResult result2 = new GameResult(16.0, 33.0); + + // Then + assertThat(result1).isNotEqualTo(result2); + } + + @Test + @DisplayName("GameResult ska vara lika med sig själv") + void should_beEqual_when_sameInstance() { + // Given + GameResult result = new GameResult(15.5, 32.8); + + // Then + assertThat(result).isEqualTo(result); + } + + @Test + @DisplayName("toString ska returnera användbar representation") + void should_returnUsefulString_when_toString() { + // Given + GameResult result = new GameResult(15.5, 32.8); + + // When + String str = result.toString(); + + // Then + assertThat(str) + .contains("GameResult") + .contains("15.5") + .contains("32.8"); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameStateTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameStateTest.java new file mode 100644 index 0000000..c39607b --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/GameStateTest.java @@ -0,0 +1,128 @@ +package se.pumfoilgame.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("GameState Value Object Tests") +class GameStateTest { + + private final Contestant testContestant = new Contestant("test@example.com", "Test User"); + + @Test + @DisplayName("idle() ska skapa IDLE state utan contestant") + void should_createIdleState_when_usingIdleFactory() { + GameState state = GameState.idle(); + + assertThat(state.type()).isEqualTo(GameState.GameStateType.IDLE); + assertThat(state.currentContestant()).isEmpty(); + assertThat(state.isIdle()).isTrue(); + assertThat(state.isInProgress()).isFalse(); + } + + @Test + @DisplayName("inProgress() ska skapa IN_PROGRESS state med contestant") + void should_createInProgressState_when_usingInProgressFactory() { + GameState state = GameState.inProgress(testContestant); + + assertThat(state.type()).isEqualTo(GameState.GameStateType.IN_PROGRESS); + assertThat(state.currentContestant()).isPresent(); + assertThat(state.currentContestant()).contains(testContestant); + assertThat(state.isIdle()).isFalse(); + assertThat(state.isInProgress()).isTrue(); + } + + @Test + @DisplayName("inProgress() ska kasta exception när contestant är null") + void should_throwException_when_inProgressWithNull() { + assertThatThrownBy(() -> GameState.inProgress(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Contestant cannot be null"); + } + + @Test + @DisplayName("Constructor ska kasta exception för IDLE med contestant") + void should_throwException_when_idleWithContestant() { + assertThatThrownBy(() -> new GameState( + GameState.GameStateType.IDLE, + Optional.of(testContestant) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IDLE state cannot have a contestant"); + } + + @Test + @DisplayName("Constructor ska kasta exception för IN_PROGRESS utan contestant") + void should_throwException_when_inProgressWithoutContestant() { + assertThatThrownBy(() -> new GameState( + GameState.GameStateType.IN_PROGRESS, + Optional.empty() + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IN_PROGRESS state must have a contestant"); + } + + @Test + @DisplayName("isPlayingContestant ska returnera true för rätt contestant i IN_PROGRESS") + void should_returnTrue_when_sameContestantPlaying() { + GameState state = GameState.inProgress(testContestant); + + assertThat(state.isPlayingContestant(testContestant)).isTrue(); + } + + @Test + @DisplayName("isPlayingContestant ska returnera false för annan contestant") + void should_returnFalse_when_differentContestant() { + Contestant other = new Contestant("other@example.com", "Other User"); + GameState state = GameState.inProgress(testContestant); + + assertThat(state.isPlayingContestant(other)).isFalse(); + } + + @Test + @DisplayName("isPlayingContestant ska returnera false i IDLE state") + void should_returnFalse_when_idle() { + GameState state = GameState.idle(); + + assertThat(state.isPlayingContestant(testContestant)).isFalse(); + } + + @Test + @DisplayName("isPlayingContestant ska returnera false för null") + void should_returnFalse_when_nullContestant() { + GameState state = GameState.inProgress(testContestant); + + assertThat(state.isPlayingContestant(null)).isFalse(); + } + + @Test + @DisplayName("Två IDLE states ska vara lika") + void should_beEqual_when_bothIdle() { + GameState state1 = GameState.idle(); + GameState state2 = GameState.idle(); + + assertThat(state1).isEqualTo(state2); + } + + @Test + @DisplayName("Två IN_PROGRESS med samma contestant ska vara lika") + void should_beEqual_when_sameContestant() { + GameState state1 = GameState.inProgress(testContestant); + GameState state2 = GameState.inProgress(testContestant); + + assertThat(state1).isEqualTo(state2); + } + + @Test + @DisplayName("IDLE och IN_PROGRESS ska inte vara lika") + void should_notBeEqual_when_differentTypes() { + GameState idle = GameState.idle(); + GameState inProgress = GameState.inProgress(testContestant); + + assertThat(idle).isNotEqualTo(inProgress); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/LeaderboardEntryTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/LeaderboardEntryTest.java new file mode 100644 index 0000000..7ef0a86 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/LeaderboardEntryTest.java @@ -0,0 +1,140 @@ +package se.pumfoilgame.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("LeaderboardEntry Entity Tests") +class LeaderboardEntryTest { + + private final Contestant testContestant = new Contestant("test@example.com", "Test User"); + private final GameResult testResult = new GameResult(15.5, 32.8); + + @Test + @DisplayName("Ska skapa LeaderboardEntry med giltiga värden") + void should_createEntry_when_validValues() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + + assertThat(entry.getContestant()).isEqualTo(testContestant); + assertThat(entry.getResult()).isEqualTo(testResult); + } + + @Test + @DisplayName("Ska kasta exception när contestant är null") + void should_throwException_when_contestantNull() { + assertThatThrownBy(() -> new LeaderboardEntry(null, testResult)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Contestant cannot be null"); + } + + @Test + @DisplayName("Ska kasta exception när result är null") + void should_throwException_when_resultNull() { + assertThatThrownBy(() -> new LeaderboardEntry(testContestant, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Result cannot be null"); + } + + @Test + @DisplayName("withResult ska skapa ny instans med uppdaterat resultat") + void should_createNewInstance_when_withResult() { + LeaderboardEntry original = new LeaderboardEntry(testContestant, testResult); + GameResult newResult = new GameResult(10.0, 25.0); + + LeaderboardEntry updated = original.withResult(newResult); + + assertThat(updated.getContestant()).isEqualTo(original.getContestant()); + assertThat(updated.getResult()).isEqualTo(newResult); + assertThat(original.getResult()).isEqualTo(testResult); // Original oförändrad + assertThat(updated).isNotSameAs(original); // Olika instanser + assertThat(updated).isEqualTo(original); // Men samma identity (contestant) + } + + @Test + @DisplayName("shouldUpdateWith ska returnera true när nytt resultat är bättre") + void should_returnTrue_when_newResultBetter() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + GameResult betterResult = new GameResult(10.0, 25.0); // Snabbare + + assertThat(entry.shouldUpdateWith(betterResult)).isTrue(); + } + + @Test + @DisplayName("shouldUpdateWith ska returnera false när nytt resultat är sämre") + void should_returnFalse_when_newResultWorse() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + GameResult worseResult = new GameResult(20.0, 45.0); // Långsammare + + assertThat(entry.shouldUpdateWith(worseResult)).isFalse(); + } + + @Test + @DisplayName("shouldUpdateWith ska returnera false när tiderna är lika") + void should_returnFalse_when_timesEqual() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + GameResult sameResult = new GameResult(10.0, 32.8); // Samma endTime + + assertThat(entry.shouldUpdateWith(sameResult)).isFalse(); + } + + @Test + @DisplayName("shouldUpdateWith ska kasta exception för null") + void should_throwException_when_shouldUpdateWithNull() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + + assertThatThrownBy(() -> entry.shouldUpdateWith(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Cannot compare with null"); + } + + @Test + @DisplayName("getContestantEmail ska returnera contestant email") + void should_returnEmail_when_getContestantEmail() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + + assertThat(entry.getContestantEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("getEndTime ska returnera result endTime") + void should_returnEndTime_when_getEndTime() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + + assertThat(entry.getEndTime()).isEqualTo(32.8); + } + + @Test + @DisplayName("Två entries med samma contestant ska vara lika") + void should_beEqual_when_sameContestant() { + GameResult otherResult = new GameResult(20.0, 40.0); + LeaderboardEntry entry1 = new LeaderboardEntry(testContestant, testResult); + LeaderboardEntry entry2 = new LeaderboardEntry(testContestant, otherResult); + + assertThat(entry1).isEqualTo(entry2); + assertThat(entry1.hashCode()).isEqualTo(entry2.hashCode()); + } + + @Test + @DisplayName("Två entries med olika contestants ska inte vara lika") + void should_notBeEqual_when_differentContestants() { + Contestant other = new Contestant("other@example.com", "Other User"); + LeaderboardEntry entry1 = new LeaderboardEntry(testContestant, testResult); + LeaderboardEntry entry2 = new LeaderboardEntry(other, testResult); + + assertThat(entry1).isNotEqualTo(entry2); + } + + @Test + @DisplayName("toString ska returnera användbar representation") + void should_returnUsefulString_when_toString() { + LeaderboardEntry entry = new LeaderboardEntry(testContestant, testResult); + String str = entry.toString(); + + assertThat(str) + .contains("LeaderboardEntry") + .contains("test@example.com") + .contains("Test User"); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/QueueItemTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/QueueItemTest.java new file mode 100644 index 0000000..68a0bf0 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/domain/model/QueueItemTest.java @@ -0,0 +1,65 @@ +package se.pumfoilgame.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("QueueItem Entity Tests") +class QueueItemTest { + + private final Contestant testContestant = new Contestant("test@example.com", "Test User"); + + @Test + @DisplayName("Ska skapa QueueItem med giltiga värden") + void should_createQueueItem_when_validValues() { + QueueItem item = new QueueItem(1000L, testContestant); + assertThat(item.getTimestamp()).isEqualTo(1000L); + assertThat(item.getContestant()).isEqualTo(testContestant); + } + + @Test + @DisplayName("Factory method now ska skapa QueueItem med current timestamp") + void should_createWithCurrentTime_when_usingNow() { + long before = System.currentTimeMillis(); + QueueItem item = QueueItem.now(testContestant); + long after = System.currentTimeMillis(); + + assertThat(item.getTimestamp()).isBetween(before, after); + assertThat(item.getContestant()).isEqualTo(testContestant); + } + + @Test + @DisplayName("Ska kasta exception när timestamp är negativ") + void should_throwException_when_timestampNegative() { + assertThatThrownBy(() -> new QueueItem(-1L, testContestant)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Timestamp cannot be negative"); + } + + @Test + @DisplayName("Ska kasta exception när contestant är null") + void should_throwException_when_contestantNull() { + assertThatThrownBy(() -> new QueueItem(1000L, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Contestant cannot be null"); + } + + @Test + @DisplayName("isOlderThan ska returnera true för äldre item") + void should_returnTrue_when_thisOlder() { + QueueItem older = new QueueItem(1000L, testContestant); + QueueItem newer = new QueueItem(2000L, testContestant); + assertThat(older.isOlderThan(newer)).isTrue(); + } + + @Test + @DisplayName("Två QueueItems med samma timestamp ska vara lika") + void should_beEqual_when_sameTimestamp() { + Contestant other = new Contestant("other@example.com", "Other"); + QueueItem item1 = new QueueItem(1000L, testContestant); + QueueItem item2 = new QueueItem(1000L, other); + assertThat(item1).isEqualTo(item2); + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/dto/ContestantDTO.java b/backend-quarkus/src/test/java/se/pumfoilgame/dto/ContestantDTO.java new file mode 100644 index 0000000..4ec44fb --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/dto/ContestantDTO.java @@ -0,0 +1,26 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för Contestant. + * Används för REST API-kommunikation. + * + * OBS: Name kan vara tom sträng eftersom Go-backend returnerar "" när en contestant + * har tagits bort från systemet men fortfarande finns i leaderboard. + * Quarkus-implementationen ska hantera detta bättre. + */ +public record ContestantDTO( + @JsonProperty("email") String email, + @JsonProperty("name") String name +) { + public ContestantDTO { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email cannot be null or blank"); + } + if (name == null) { + throw new IllegalArgumentException("Name cannot be null"); + } + // OBS: Name kan vara blank (tom sträng) pga Go-backend beteende + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameNotificationDTO.java b/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameNotificationDTO.java new file mode 100644 index 0000000..612e7af --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameNotificationDTO.java @@ -0,0 +1,20 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för WebSocket game notifications. + * Typer: InitGame, EndGame, AbortGame + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GameNotificationDTO( + @JsonProperty("type") String type, + @JsonProperty("userid") String userid, + @JsonProperty("name") String name, + @JsonProperty("splitTime") Double splitTime, + @JsonProperty("finishTime") Double finishTime +) { + // Helper methods removed to avoid Jackson serialization issues + // Use type.equals("InitGame") instead +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameResultDTO.java b/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameResultDTO.java new file mode 100644 index 0000000..e26422d --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/dto/GameResultDTO.java @@ -0,0 +1,24 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för GameResult. + * Innehåller resultatet från ett avslutat spel. + */ +public record GameResultDTO( + @JsonProperty("splitTime") double splitTime, + @JsonProperty("endTime") double endTime +) { + public GameResultDTO { + if (splitTime < 0) { + throw new IllegalArgumentException("Split time cannot be negative"); + } + if (endTime < 0) { + throw new IllegalArgumentException("End time cannot be negative"); + } + if (endTime < splitTime) { + throw new IllegalArgumentException("End time cannot be less than split time"); + } + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java b/backend-quarkus/src/test/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java new file mode 100644 index 0000000..97220e2 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/dto/LeaderboardEntryDTO.java @@ -0,0 +1,13 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för LeaderboardEntry. + * Representerar en post i leaderboard med deltagare och resultat. + */ +public record LeaderboardEntryDTO( + @JsonProperty("contestant") ContestantDTO contestant, + @JsonProperty("result") GameResultDTO result +) { +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/dto/QueueItemDTO.java b/backend-quarkus/src/test/java/se/pumfoilgame/dto/QueueItemDTO.java new file mode 100644 index 0000000..6aa00d7 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/dto/QueueItemDTO.java @@ -0,0 +1,13 @@ +package se.pumfoilgame.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data Transfer Object för QueueItem. + * Representerar en deltagare i kön med timestamp. + */ +public record QueueItemDTO( + @JsonProperty("timestamp") long timestamp, + @JsonProperty("contestant") ContestantDTO contestant +) { +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/integration/FullGameFlowIntegrationTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/integration/FullGameFlowIntegrationTest.java new file mode 100644 index 0000000..ae94172 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/integration/FullGameFlowIntegrationTest.java @@ -0,0 +1,310 @@ +package se.pumfoilgame.integration; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.*; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.GameResultDTO; +import se.pumfoilgame.dto.LeaderboardEntryDTO; +import se.pumfoilgame.dto.QueueItemDTO; +import se.pumfoilgame.test.IntegrationTestBase; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Integration test som testar hela spelflödet från början till slut. + * Denna test körs mot Quarkus-backenden och verifierar att hela systemet fungerar tillsammans. + * + * OBS: Denna test kräver att stegen körs i ordning och INTE rensar databas mellan steg. + */ +@QuarkusTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("Full Game Flow Integration Test (mot Quarkus-backend)") +class FullGameFlowIntegrationTest extends IntegrationTestBase { + + @jakarta.inject.Inject + se.pumfoilgame.application.GameApplicationService gameApplicationService; + + private static long player1Timestamp; + private static long player2Timestamp; + private static long player3Timestamp; + + @BeforeAll + public static void setUpIntegrationTest() { + // Basklassen cleanDatabase() körs redan + } + + @Test + @Order(1) + @DisplayName("Steg 1: Systemet startar med tom databas") + void step1_systemStartsWithEmptyDatabase() { + // Reset game state + gameApplicationService.resetGameState(); + // Verifiera att allt är tomt + // OBS: Go returnerar 400 för tom contestants-lista + given().spec(requestSpec).get("/contestants") + .then().statusCode(400); + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(0)); + given().spec(requestSpec).get("/leaderboard") + .then().body("$", hasSize(0)); + } + + @Test + @Order(2) + @DisplayName("Steg 2: Lägg till tre spelare i kön") + void step2_addThreePlayersToQueue() { + // Lägg till spelare + QueueItemDTO queue1 = given().spec(requestSpec) + .body(new ContestantDTO("alice@example.com", "Alice")) + .post("/contestants") + .then().statusCode(200) + .extract().as(QueueItemDTO.class); + player1Timestamp = queue1.timestamp(); + + try { Thread.sleep(10); } catch (InterruptedException e) {} + + QueueItemDTO queue2 = given().spec(requestSpec) + .body(new ContestantDTO("bob@example.com", "Bob")) + .post("/contestants") + .then().statusCode(200) + .extract().as(QueueItemDTO.class); + player2Timestamp = queue2.timestamp(); + + try { Thread.sleep(10); } catch (InterruptedException e) {} + + QueueItemDTO queue3 = given().spec(requestSpec) + .body(new ContestantDTO("charlie@example.com", "Charlie")) + .post("/contestants") + .then().statusCode(200) + .extract().as(QueueItemDTO.class); + player3Timestamp = queue3.timestamp(); + + // Verifiera att alla tre finns i kön + QueueItemDTO[] queue = given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(3)) + .extract().as(QueueItemDTO[].class); + + assertThat(queue).extracting(q -> q.contestant().email()) + .containsExactly("alice@example.com", "bob@example.com", "charlie@example.com"); + + // Verifiera att alla finns som contestants + given().spec(requestSpec) + .queryParam("filter", "ALL") + .get("/contestants") + .then().body("$", hasSize(3)); + + // Verifiera att alla är enqueued + given().spec(requestSpec) + .queryParam("filter", "ENQUEUED") + .get("/contestants") + .then().body("$", hasSize(3)); + + // Verifiera att ingen är NOT_ENQUEUED (Go returnerar 400 när tom) + given().spec(requestSpec) + .queryParam("filter", "NOT_ENQUEUED") + .get("/contestants") + .then().statusCode(400); + } + + @Test + @Order(3) + @DisplayName("Steg 3: Starta spel med Bob (mitten i kön)") + void step3_startGameWithBob() { + // Starta spel med Bob (mitten) + QueueItemDTO startedGame = given().spec(requestSpec) + .queryParam("timestamp", player2Timestamp) + .post("/game-start") + .then().statusCode(200) + .body("contestant.email", equalTo("bob@example.com")) + .extract().as(QueueItemDTO.class); + + // Verifiera att Bob inte längre är i kön + QueueItemDTO[] queue = given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(2)) + .extract().as(QueueItemDTO[].class); + + assertThat(queue).extracting(q -> q.contestant().email()) + .containsExactly("alice@example.com", "charlie@example.com"); + + // Verifiera filter NOT_ENQUEUED (Bob ska finnas där nu) + given().spec(requestSpec) + .queryParam("filter", "NOT_ENQUEUED") + .get("/contestants") + .then().body("$", hasSize(1)) + .body("email[0]", equalTo("bob@example.com")); + } + + @Test + @Order(4) + @DisplayName("Steg 4: Försök starta ett till spel (ska misslyckas)") + void step4_attemptToStartAnotherGame() { + given().spec(requestSpec) + .post("/game-start") + .then().statusCode(500); + } + + @Test + @Order(5) + @DisplayName("Steg 5: Avbryt Bobs spel") + void step5_abortBobsGame() { + given().spec(requestSpec) + .post("/game-abort") + .then().statusCode(200); + + // Verifiera att vi nu kan starta ett nytt spel + QueueItemDTO startedGame = given().spec(requestSpec) + .post("/game-start") + .then().statusCode(200) + .body("contestant.email", equalTo("alice@example.com")) + .extract().as(QueueItemDTO.class); + + // Leaderboard ska fortfarande vara tom + given().spec(requestSpec).get("/leaderboard") + .then().body("$", hasSize(0)); + } + + @Test + @Order(6) + @DisplayName("Steg 6: Avsluta Alice's spel med resultat") + void step6_finishAlicesGame() { + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec) + .body(new GameResultDTO(15.5, 32.8)) + .post("/game-finish") + .then().statusCode(200) + .body("$", hasSize(1)) + .extract().as(LeaderboardEntryDTO[].class); + + assertThat(leaderboard[0].contestant().email()).isEqualTo("alice@example.com"); + assertThat(leaderboard[0].result().splitTime()).isEqualTo(15.5); + assertThat(leaderboard[0].result().endTime()).isEqualTo(32.8); + } + + @Test + @Order(7) + @DisplayName("Steg 7: Spela Charlie's spel (bättre tid än Alice)") + void step7_playCharliesGame() { + given().spec(requestSpec) + .post("/game-start") + .then().body("contestant.email", equalTo("charlie@example.com")); + + given().spec(requestSpec) + .body(new GameResultDTO(12.0, 28.5)) + .post("/game-finish"); + + // Verifiera leaderboard - Charlie först (bättre tid) + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec) + .get("/leaderboard") + .then().body("$", hasSize(2)) + .extract().as(LeaderboardEntryDTO[].class); + + assertThat(leaderboard[0].contestant().email()).isEqualTo("charlie@example.com"); + assertThat(leaderboard[0].result().endTime()).isEqualTo(28.5); + + assertThat(leaderboard[1].contestant().email()).isEqualTo("alice@example.com"); + assertThat(leaderboard[1].result().endTime()).isEqualTo(32.8); + } + + @Test + @Order(8) + @DisplayName("Steg 8: Bob gör om och gör rätt (bästa tid)") + void step8_bobRedeems() { + // Lägg tillbaka Bob i kön + given().spec(requestSpec) + .body(new ContestantDTO("bob@example.com", "Bob")) + .post("/contestants"); + + // Starta och avsluta spel + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec) + .body(new GameResultDTO(10.0, 25.0)) + .post("/game-finish"); + + // Verifiera leaderboard - Bob först + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec) + .get("/leaderboard") + .then().body("$", hasSize(3)) + .extract().as(LeaderboardEntryDTO[].class); + + assertThat(leaderboard[0].contestant().email()).isEqualTo("bob@example.com"); + assertThat(leaderboard[0].result().endTime()).isEqualTo(25.0); + + assertThat(leaderboard[1].contestant().email()).isEqualTo("charlie@example.com"); + assertThat(leaderboard[2].contestant().email()).isEqualTo("alice@example.com"); + } + + @Test + @Order(9) + @DisplayName("Steg 9: Alice försöker igen men får sämre tid") + void step9_aliceTiesAgainWithWorseTime() { + // Lägg tillbaka Alice i kön + given().spec(requestSpec) + .body(new ContestantDTO("alice@example.com", "Alice")) + .post("/contestants"); + + // Spela med sämre tid + given().spec(requestSpec).post("/game-start"); + given().spec(requestSpec) + .body(new GameResultDTO(20.0, 40.0)) + .post("/game-finish"); + + // Verifiera att hennes gamla (bättre) tid behålls + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec) + .get("/leaderboard") + .then().body("$", hasSize(3)) + .extract().as(LeaderboardEntryDTO[].class); + + LeaderboardEntryDTO aliceEntry = leaderboard[2]; + assertThat(aliceEntry.contestant().email()).isEqualTo("alice@example.com"); + assertThat(aliceEntry.result().endTime()).isEqualTo(32.8); // Ursprunglig tid + assertThat(aliceEntry.result().splitTime()).isEqualTo(15.5); + } + + @Test + @Order(10) + @DisplayName("Steg 10: Ta bort Charlie från systemet") + void step10_removeCharlie() { + given().spec(requestSpec) + .delete("/contestants/charlie@example.com") + .then().statusCode(200); + + // Verifiera att Charlie är borta från contestants + given().spec(requestSpec) + .get("/contestants") + .then().body("$", hasSize(2)) + .body("email", not(hasItem("charlie@example.com"))); + + // Men leaderboard ska fortfarande ha Charlie's resultat + given().spec(requestSpec) + .get("/leaderboard") + .then().body("$", hasSize(3)); + } + + @Test + @Order(11) + @DisplayName("Steg 11: Verifiera slutlig status") + void step11_verifyFinalState() { + // 2 contestants kvar (Alice och Bob, Charlie borttagen) + given().spec(requestSpec).get("/contestants") + .then().body("$", hasSize(2)); + + // Tom kö (alla har spelat) + given().spec(requestSpec).get("/queue") + .then().body("$", hasSize(0)); + + // 3 poster i leaderboard + LeaderboardEntryDTO[] leaderboard = given().spec(requestSpec) + .get("/leaderboard") + .then().body("$", hasSize(3)) + .extract().as(LeaderboardEntryDTO[].class); + + // Verifiera slutlig ordning + assertThat(leaderboard[0].contestant().email()).isEqualTo("bob@example.com"); + assertThat(leaderboard[1].contestant().email()).isEqualTo("charlie@example.com"); + assertThat(leaderboard[2].contestant().email()).isEqualTo("alice@example.com"); + + // Verifiera att Charlie's namn fortfarande finns i leaderboard + assertThat(leaderboard[1].contestant().name()).isEmpty(); // Go-backend returnerar tom sträng när contestant är borttagen + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/test/GoBackendTestBase.java b/backend-quarkus/src/test/java/se/pumfoilgame/test/GoBackendTestBase.java new file mode 100644 index 0000000..40bb4c8 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/test/GoBackendTestBase.java @@ -0,0 +1,74 @@ +package se.pumfoilgame.test; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import se.pumfoilgame.application.GameApplicationService; + +import static io.restassured.RestAssured.given; + +/** + * Basklass för tester som körs mot Quarkus-backenden. + * Konfigurerar RestAssured att peka mot http://localhost:8081/api/v1 + */ +public abstract class GoBackendTestBase { + + protected static final String QUARKUS_BACKEND_BASE_URL = "http://localhost:8081"; + protected static final String API_BASE_PATH = "/api/v1"; + protected static final String MONGODB_URL = "mongodb://localhost:27017"; + + protected RequestSpecification requestSpec; + + @Inject + GameApplicationService gameApplicationService; + + @BeforeEach + public void setUp() { + // Konfigurera RestAssured att peka mot Quarkus-backend + requestSpec = new RequestSpecBuilder() + .setBaseUri(QUARKUS_BACKEND_BASE_URL) + .setBasePath(API_BASE_PATH) + .setContentType(ContentType.JSON) + .setAccept(ContentType.JSON) + .build(); + + // Rensa databasen mellan tester (anslut direkt till lokal MongoDB) + cleanDatabase(); + } + + /** + * Rensa all data från databasen mellan tester. + */ + private void cleanDatabase() { + // Reset game state + if (gameApplicationService != null) { + gameApplicationService.resetGameState(); + } + + // Avbryt eventuellt pågående spel först + try { + given() + .baseUri(QUARKUS_BACKEND_BASE_URL) + .basePath(API_BASE_PATH) + .post("/game-abort"); + } catch (Exception e) { + // Ignorera om abort misslyckas + } + + // Rensa databas-collectioner (använd test database name) + try (MongoClient client = MongoClients.create(MONGODB_URL)) { + var database = client.getDatabase("foildb-test"); + database.getCollection("contestants").drop(); + database.getCollection("queue").drop(); + database.getCollection("leaderboard").drop(); + } catch (Exception e) { + // Ignorera om databas inte finns eller inte kan nås + System.err.println("Warning: Could not clean database: " + e.getMessage()); + } + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/test/IntegrationTestBase.java b/backend-quarkus/src/test/java/se/pumfoilgame/test/IntegrationTestBase.java new file mode 100644 index 0000000..ed538cb --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/test/IntegrationTestBase.java @@ -0,0 +1,63 @@ +package se.pumfoilgame.test; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; + +import static io.restassured.RestAssured.given; + +/** + * Basklass för integration tester som körs mot Quarkus-backenden. + * Rensar ENDAST före första testet, inte mellan varje test. + */ +public abstract class IntegrationTestBase { + + protected static final String QUARKUS_BACKEND_BASE_URL = "http://localhost:8081"; + protected static final String API_BASE_PATH = "/api/v1"; + protected static final String MONGODB_URL = "mongodb://localhost:27017"; + + protected static RequestSpecification requestSpec; + + @BeforeAll + public static void setUpOnce() { + // Konfigurera RestAssured att peka mot Quarkus-backend + requestSpec = new RequestSpecBuilder() + .setBaseUri(QUARKUS_BACKEND_BASE_URL) + .setBasePath(API_BASE_PATH) + .setContentType(ContentType.JSON) + .setAccept(ContentType.JSON) + .build(); + + // Rensa databasen ENDAST före hela testsviten + cleanDatabase(); + } + + /** + * Rensa all data från databasen. + */ + private static void cleanDatabase() { + // Avbryt eventuellt pågående spel först + try { + given() + .baseUri(QUARKUS_BACKEND_BASE_URL) + .basePath(API_BASE_PATH) + .post("/game-abort"); + } catch (Exception e) { + // Ignorera om abort misslyckas + } + + // Rensa databas-collectioner (använd test database name) + try (MongoClient client = MongoClients.create(MONGODB_URL)) { + var database = client.getDatabase("foildb-test"); + database.getCollection("contestants").drop(); + database.getCollection("queue").drop(); + database.getCollection("leaderboard").drop(); + } catch (Exception e) { + // Ignorera om databas inte finns eller inte kan nås + System.err.println("Warning: Could not clean database: " + e.getMessage()); + } + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/test/MongoDBResource.java b/backend-quarkus/src/test/java/se/pumfoilgame/test/MongoDBResource.java new file mode 100644 index 0000000..4c29b96 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/test/MongoDBResource.java @@ -0,0 +1,50 @@ +package se.pumfoilgame.test; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import org.testcontainers.containers.MongoDBContainer; + +import java.util.Map; + +/** + * QuarkusTestResource för MongoDB TestContainer. + * Startar en MongoDB-container för tester och tillhandahåller connection string. + */ +public class MongoDBResource implements QuarkusTestResourceLifecycleManager { + + private static MongoDBContainer mongoDBContainer; + + @Override + public Map start() { + mongoDBContainer = new MongoDBContainer("mongo:7.0") + .withExposedPorts(27017); + mongoDBContainer.start(); + + return Map.of( + "quarkus.mongodb.connection-string", mongoDBContainer.getReplicaSetUrl(), + "quarkus.mongodb.database", "foildb" + ); + } + + @Override + public void stop() { + if (mongoDBContainer != null) { + mongoDBContainer.stop(); + } + } + + /** + * Rensa all data från databasen mellan tester. + */ + public static void cleanDatabase() { + if (mongoDBContainer != null && mongoDBContainer.isRunning()) { + try (MongoClient client = MongoClients.create(mongoDBContainer.getReplicaSetUrl())) { + var database = client.getDatabase("foildb"); + database.getCollection("contestants").drop(); + database.getCollection("queue").drop(); + database.getCollection("leaderboard").drop(); + } + } + } +} diff --git a/backend-quarkus/src/test/java/se/pumfoilgame/websocket/WebSocketApiTest.java b/backend-quarkus/src/test/java/se/pumfoilgame/websocket/WebSocketApiTest.java new file mode 100644 index 0000000..aecb341 --- /dev/null +++ b/backend-quarkus/src/test/java/se/pumfoilgame/websocket/WebSocketApiTest.java @@ -0,0 +1,288 @@ +package se.pumfoilgame.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.websocket.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import se.pumfoilgame.dto.ContestantDTO; +import se.pumfoilgame.dto.GameNotificationDTO; +import se.pumfoilgame.dto.GameResultDTO; +import se.pumfoilgame.test.GoBackendTestBase; +import se.pumfoilgame.test.MongoDBResource; + +import java.net.URI; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tester för WebSocket notifications. + * Dessa tester körs mot Quarkus-backenden för att verifiera WebSocket-beteende. + */ +@QuarkusTest +@DisplayName("WebSocket API Tests (mot Quarkus-backend)") +class WebSocketApiTest { + + private static final String QUARKUS_BASE_URL = "http://localhost:8081"; + private static final String WS_URL = "ws://localhost:8081/api/v1/ws"; + + private WebSocketContainer container; + private Session session; + private BlockingQueue messageQueue; + private ObjectMapper objectMapper; + private io.restassured.specification.RequestSpecification requestSpec; + + @jakarta.inject.Inject + se.pumfoilgame.application.GameApplicationService gameApplicationService; + + @BeforeEach + public void setUpWebSocket() throws Exception { + // Clean database directly (not via Go backend) + cleanDatabase(); + + // Configure requestSpec to point to Quarkus + requestSpec = new io.restassured.builder.RequestSpecBuilder() + .setBaseUri(QUARKUS_BASE_URL) + .setBasePath("/api/v1") + .setContentType(io.restassured.http.ContentType.JSON) + .setAccept(io.restassured.http.ContentType.JSON) + .build(); + + objectMapper = new ObjectMapper(); + messageQueue = new LinkedBlockingQueue<>(); + container = ContainerProvider.getWebSocketContainer(); + + // Skapa WebSocket-klient programmatiskt + ClientEndpointConfig config = ClientEndpointConfig.Builder.create().build(); + session = container.connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + session.addMessageHandler(String.class, message -> messageQueue.offer(message)); + } + + @Override + public void onError(Session session, Throwable throwable) { + System.err.println("WebSocket error: " + throwable.getMessage()); + } + }, config, URI.create(WS_URL)); + } + + private void cleanDatabase() { + // Reset game state in GameApplicationService + gameApplicationService.resetGameState(); + + // Abort any ongoing game via Quarkus backend + try { + given() + .baseUri(QUARKUS_BASE_URL) + .basePath("/api/v1") + .post("/game-abort"); + } catch (Exception e) { + // Ignore if abort fails + } + + // Clean database collections directly (use test database name) + try (com.mongodb.client.MongoClient client = com.mongodb.client.MongoClients.create("mongodb://localhost:27017")) { + var database = client.getDatabase("foildb-test"); + database.getCollection("contestants").drop(); + database.getCollection("queue").drop(); + database.getCollection("leaderboard").drop(); + } catch (Exception e) { + System.err.println("Warning: Could not clean database: " + e.getMessage()); + } + } + + @AfterEach + public void tearDownWebSocket() throws Exception { + if (session != null && session.isOpen()) { + session.close(); + } + } + + @Test + @DisplayName("WebSocket - ska skicka InitGame notification när spel startas") + void should_sendInitGameNotification_when_gameStarts() throws Exception { + System.out.println("TEST: About to create contestant"); + // Lägg till contestant + var response1 = given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + System.out.println("TEST: Contestant created, status: " + response1.statusCode()); + + // Starta spel + System.out.println("TEST: About to start game"); + var response2 = given().spec(requestSpec).post("/game-start"); + System.out.println("TEST: Game start called, status: " + response2.statusCode()); + + // Vänta på notification + String message = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + + GameNotificationDTO notification = objectMapper.readValue(message, GameNotificationDTO.class); + assertThat(notification.type()).isEqualTo("InitGame"); + assertThat(notification.userid()).isEqualTo("player@example.com"); + assertThat(notification.name()).isEqualTo("Test Player"); + assertThat(notification.splitTime()).isNull(); + assertThat(notification.finishTime()).isNull(); + } + + @Test + @DisplayName("WebSocket - ska skicka EndGame notification när spel avslutas") + void should_sendEndGameNotification_when_gameFinishes() throws Exception { + // Lägg till contestant och starta spel + given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Rensa InitGame notification + messageQueue.poll(5, TimeUnit.SECONDS); + + // Avsluta spel + given().spec(requestSpec) + .body(new GameResultDTO(15.5, 32.8)) + .post("/game-finish"); + + // Vänta på EndGame notification + String message = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + + GameNotificationDTO notification = objectMapper.readValue(message, GameNotificationDTO.class); + assertThat(notification.type()).isEqualTo("EndGame"); + assertThat(notification.splitTime()).isEqualTo(15.5); + assertThat(notification.finishTime()).isEqualTo(32.8); + assertThat(notification.userid()).isNull(); + assertThat(notification.name()).isNull(); + } + + @Test + @DisplayName("WebSocket - ska skicka AbortGame notification när spel avbryts") + void should_sendAbortGameNotification_when_gameAborts() throws Exception { + // Lägg till contestant och starta spel + given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Rensa InitGame notification + messageQueue.poll(5, TimeUnit.SECONDS); + + // Avbryt spel + given().spec(requestSpec).post("/game-abort"); + + // Vänta på AbortGame notification + String message = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + + GameNotificationDTO notification = objectMapper.readValue(message, GameNotificationDTO.class); + assertThat(notification.type()).isEqualTo("AbortGame"); + assertThat(notification.userid()).isNull(); + assertThat(notification.name()).isNull(); + assertThat(notification.splitTime()).isNull(); + assertThat(notification.finishTime()).isNull(); + } + + @Test + @DisplayName("WebSocket - ska skicka notifications till flera anslutna klienter") + void should_sendNotificationsToMultipleClients_when_multipleConnected() throws Exception { + // Anslut ytterligare en klient + BlockingQueue secondClientQueue = new LinkedBlockingQueue<>(); + ClientEndpointConfig secondConfig = ClientEndpointConfig.Builder.create().build(); + Session secondSession = container.connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + session.addMessageHandler(String.class, message -> secondClientQueue.offer(message)); + } + }, secondConfig, URI.create(WS_URL)); + + try { + // Lägg till contestant och starta spel + given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + + // Båda klienterna ska få meddelandet + String message1 = messageQueue.poll(5, TimeUnit.SECONDS); + String message2 = secondClientQueue.poll(5, TimeUnit.SECONDS); + + assertThat(message1).isNotNull(); + assertThat(message2).isNotNull(); + + GameNotificationDTO notification1 = objectMapper.readValue(message1, GameNotificationDTO.class); + GameNotificationDTO notification2 = objectMapper.readValue(message2, GameNotificationDTO.class); + + assertThat(notification1.type()).isEqualTo("InitGame"); + assertThat(notification2.type()).isEqualTo("InitGame"); + assertThat(notification1.userid()).isEqualTo(notification2.userid()); + + } finally { + if (secondSession.isOpen()) { + secondSession.close(); + } + } + } + + @Test + @DisplayName("WebSocket - ska skicka sekvens av notifications för komplett spelflöde") + void should_sendSequenceOfNotifications_when_completeGameFlow() throws Exception { + // Lägg till contestant + given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + + // Starta spel + given().spec(requestSpec).post("/game-start"); + String initMessage = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(initMessage).isNotNull(); + GameNotificationDTO initNotification = objectMapper.readValue(initMessage, GameNotificationDTO.class); + assertThat(initNotification.type().equals("InitGame")).isTrue(); + + // Avsluta spel + given().spec(requestSpec) + .body(new GameResultDTO(15.5, 32.8)) + .post("/game-finish"); + String endMessage = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(endMessage).isNotNull(); + GameNotificationDTO endNotification = objectMapper.readValue(endMessage, GameNotificationDTO.class); + assertThat(endNotification.type().equals("EndGame")).isTrue(); + + // Starta nytt spel med ny spelare + given().spec(requestSpec) + .body(new ContestantDTO("player2@example.com", "Second Player")) + .post("/contestants"); + given().spec(requestSpec).post("/game-start"); + String secondInitMessage = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(secondInitMessage).isNotNull(); + GameNotificationDTO secondInitNotification = objectMapper.readValue(secondInitMessage, GameNotificationDTO.class); + assertThat(secondInitNotification.type().equals("InitGame")).isTrue(); + assertThat(secondInitNotification.userid()).isEqualTo("player2@example.com"); + + // Avbryt detta spel + given().spec(requestSpec).post("/game-abort"); + String abortMessage = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(abortMessage).isNotNull(); + GameNotificationDTO abortNotification = objectMapper.readValue(abortMessage, GameNotificationDTO.class); + assertThat(abortNotification.type().equals("AbortGame")).isTrue(); + } + + @Test + @DisplayName("WebSocket - ska inte skicka notification om inget händer") + void should_notSendNotification_when_noGameActivity() throws Exception { + // Lägg till contestant (utan att starta spel) + given().spec(requestSpec) + .body(new ContestantDTO("player@example.com", "Test Player")) + .post("/contestants"); + + // Ingen notification ska skickas + String message = messageQueue.poll(2, TimeUnit.SECONDS); + assertThat(message).isNull(); + } +} diff --git a/backend-quarkus/src/test/resources/application.properties b/backend-quarkus/src/test/resources/application.properties new file mode 100644 index 0000000..c13ac9d --- /dev/null +++ b/backend-quarkus/src/test/resources/application.properties @@ -0,0 +1,6 @@ +# Test configuration pointing to Go backend +test.backend.baseUrl=http://localhost:18089 + +# MongoDB test configuration (will be overridden by TestContainers in tests) +quarkus.mongodb.connection-string=mongodb://localhost:27017 +quarkus.mongodb.database=foildb-test diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a4fd262..d3aecfe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -567,6 +567,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -691,6 +692,7 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.13.tgz", "integrity": "sha512-rG5J5Ek5Hg+Tz2NjkNOaG6PupiNK/lPfophXpsR1t/nWujqnMWX2krahD/i6kgD+jNWNKCJCYSOVvCx/BHOtKA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -740,6 +742,7 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.13.tgz", "integrity": "sha512-4ZqrNp1PoZo7VNvW+sbSc2CB2axP1sCH2wXl8B0wdjsj8JY1hF1OhuugwhpAHtGxqewed2kCXayE+ZJqSTV4jw==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -756,6 +759,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.13.tgz", "integrity": "sha512-TzWcrkopyjFF+WeDr2cRe8CcHjU72KfYV3Sm2TkBkcXrkYX5sDjGWrBGrG3hRB4e4okqchrOCvm1MiTdy2vKMA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -777,6 +781,7 @@ "integrity": "sha512-DBSh4AQwkiJDSiVvJATRmjxf6wyUs9pwQLgaFdSlfuTRO+sdb0J2z1r3BYm8t0IqdoyXzdZq2YCH43EmyvD71g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -835,6 +840,7 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.13.tgz", "integrity": "sha512-8mbWHMgO95OuFV1Ejy4oKmbe9NOJ3WazQf/f7wks8Bck7pcihd0IKhlPBNjFllbF5o+04EYSwFhEtvEgjMDClA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -869,6 +875,7 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.13.tgz", "integrity": "sha512-tu7ZzY6qD3ATdWFzcTcsAKe7M6cJeWbT/4/bF9unyGO3XBPcNYDKoiz10+7ap2PUd0fmPwvuvTvSNJiFEBnB8Q==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -953,6 +960,7 @@ "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -3442,6 +3450,7 @@ "integrity": "sha512-b2BudQY/Si4Y2a0PdZZL6BeJtl8llgeZa7U2j47aaJSCeAl1e4UI7y8a9bSkO3o/ZbZrgT5muy/34JbsjfIWxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^2.4.7", "@inquirer/confirm": "^3.1.22", @@ -4953,6 +4962,7 @@ "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -5280,6 +5290,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5369,6 +5380,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5823,6 +5835,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", @@ -9023,7 +9036,8 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-worker": { "version": "27.5.1", @@ -9169,6 +9183,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -9440,6 +9455,7 @@ "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -11467,6 +11483,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -12256,6 +12273,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13416,6 +13434,7 @@ "integrity": "sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13552,6 +13571,7 @@ "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -13606,6 +13626,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13779,6 +13800,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13828,7 +13850,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "2.2.1", @@ -13885,6 +13908,7 @@ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14155,6 +14179,7 @@ "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -14696,6 +14721,7 @@ "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -14773,6 +14799,7 @@ "integrity": "sha512-dljXhUgx3HqKP2d8J/fUMvhxGhzjeNVarDLcbO/EWMSgRizDkxHQDZQaLFL5VJY9tRBj2Gz+rvCEYYvhbqPHNA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -14833,6 +14860,7 @@ "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -15004,6 +15032,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15361,7 +15390,8 @@ "version": "0.14.10", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==", - "license": "MIT" + "license": "MIT", + "peer": true } } }