From c04d4fca768b4d61aca681acb88e9e554f741a35 Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 24 Apr 2026 14:14:59 +0200 Subject: [PATCH 1/8] Refactor: Add LobbyCleanupJob for periodic expired lobby cleanup - isolate failures per lobby during cleanup - notify connected sessions about closed lobbies Refs #124 Co-authored-by: Copilot --- .../server/domain/lobby/LobbyCleanupJob.java | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java new file mode 100644 index 0000000..47c0413 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/lobby/LobbyCleanupJob.java @@ -0,0 +1,100 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; +import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session; +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; +import java.time.Duration; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** Periodically removes expired empty lobbies and notifies connected sessions. */ +public class LobbyCleanupJob implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(LobbyCleanupJob.class); + + private final LobbyManager lobbyManager; + private final SessionManager sessionManager; + private final ResponseDispatcher responseDispatcher; + private final Duration expiryThreshold; + + /** + * Creates a new cleanup job for expired empty lobbies. + * + * @param lobbyManager manager used to find and remove expired lobbies + * @param sessionManager manager used to resolve connected sessions + * @param responseDispatcher dispatcher used to notify clients about closed lobbies + * @param expiryThreshold age threshold that marks an empty lobby as expired + */ + public LobbyCleanupJob( + LobbyManager lobbyManager, + SessionManager sessionManager, + ResponseDispatcher responseDispatcher, + Duration expiryThreshold) { + this.lobbyManager = lobbyManager; + this.sessionManager = sessionManager; + this.responseDispatcher = responseDispatcher; + this.expiryThreshold = expiryThreshold; + } + + /** + * Runs one cleanup cycle. + * + *

The method first fetches all expired empty lobbies. Each lobby is then processed + * independently so that a failure for one lobby does not stop the remaining cleanups. + */ + @Override + public void run() { + LOGGER.debug("Job started."); + try { + List expired; + try { + expired = lobbyManager.findEmptyLobbiesOlderThan(expiryThreshold); + } catch (Exception e) { + LOGGER.error("Lobby expiry job failed: could not fetch expired lobbies", e); + return; + } + + for (LobbyId lobbyId : expired) { + try { + lobbyManager.removeLobby(lobbyId); + broadcastLobbyClosed(lobbyId); + } catch (RuntimeException e) { + LOGGER.warn("Failed to process expired lobby {}", lobbyId.value(), e); + } + } + } finally { + LOGGER.debug("Job finished."); + } + } + + /** + * Broadcasts a lobby-closed event to all currently connected sessions. + * + * @param lobbyId id of the lobby that was closed + */ + private void broadcastLobbyClosed(LobbyId lobbyId) { + for (Session session : sessionManager.getAllSessions()) { + try { + RequestContext ctx = new RequestContext(session.getId(), 0); + SuccessResponse event = + new SuccessResponse( + ctx, + ResponseBody.builder() + .param("EVENT", "LOBBY_CLOSED") + .param("LOBBY_ID", lobbyId.value()) + .build()) {}; + responseDispatcher.dispatch(event); + } catch (RuntimeException e) { + LOGGER.warn( + "Failed to notify session {} about closed lobby {}", + session.getId().value(), + lobbyId.value(), + e); + } + } + } +} From 9023263b012c8873ffd0cf230fd618a73883c4db Mon Sep 17 00:00:00 2001 From: Jona Walpert Date: Fri, 24 Apr 2026 14:16:12 +0200 Subject: [PATCH 2/8] Refacor: Schedule LobbyCleanupJob instead of cleanup lambda Refs #124 Co-authored-by: Copilot --- .../dbis/cs108/casono/server/ServerApp.java | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index f93ef7c..a9a46c7 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -63,6 +63,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser; import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob; import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; @@ -73,11 +74,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandR import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; -import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher; -import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionDisconnectJob; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; import java.time.Duration; @@ -141,37 +138,12 @@ public class ServerApp { userRegistry, new CommandContext(lobbyManager, sessionManager)); - // Periodic cleanup: remove empty lobbies older than 30s and notify affected - // users scheduler.scheduleAtFixedRate( - () -> { - try { - var expired = - lobbyManager.findEmptyLobbiesOlderThan( - Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)); - for (var lid : expired) { - // remove lobby from manager first - lobbyManager.removeLobby(lid); - - // broadcast LOBBY_CLOSED event to all connected sessions - // (requestId=0) - for (Session s : sessionManager.getAllSessions()) { - RequestContext ctx = new RequestContext(s.getId(), 0); - SuccessResponse ev = - new SuccessResponse( - ctx, - ResponseBody.builder() - .param("EVENT", "LOBBY_CLOSED") - .param("LOBBY_ID", lid.value()) - .build()) {}; - - responseDispatcher.dispatch(ev); - } - } - } catch (Exception e) { - logger.warn("Lobby expiry job failed", e); - } - }, + new LobbyCleanupJob( + lobbyManager, + sessionManager, + responseDispatcher, + Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)), LOBBY_CLEANUP_INITIAL_DELAY_SECONDS, LOBBY_CLEANUP_PERIOD_SECONDS, TimeUnit.SECONDS); From d49f18c3aa2343e20665b85ffb79292e8c24cb7c Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 25 Apr 2026 10:44:09 +0200 Subject: [PATCH 3/8] Test: Unit tests for Tokenizer --- .../parser/tokenizer/TokenizerTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java new file mode 100644 index 0000000..5c5a2d3 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java @@ -0,0 +1,204 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class TokenizerTest { + @Test + void testEofAlwaysPresent() { + List tokens = Tokenizer.tokenize(""); + + assertEquals(1, tokens.size()); + assertEquals(RawTokenType.EOF, tokens.get(0).type()); + } + + @Test + void testSimpleCommand() { + List tokens = Tokenizer.tokenize("PING"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + assertEquals(RawTokenType.EOF, tokens.get(1).type()); + } + + @Test + void testSimpleCommandWithUnderscore() { + List tokens = Tokenizer.tokenize("JOIN_GAME"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("JOIN_GAME", tokens.get(0).value()); + assertEquals(RawTokenType.EOF, tokens.get(1).type()); + } + + @Test + void testCommandWithOneParameter() { + List tokens = Tokenizer.tokenize("GET VALUE=42"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("VALUE", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.WORD, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithOneParameterWhitespacesBetweenSeperator() { + List tokens = Tokenizer.tokenize("GET VALUE = 42"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("VALUE", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.WORD, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithOneParameterAndNewline() { + List tokens = Tokenizer.tokenize("GET\nVALUE=42"); + assertEquals(6, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GET", tokens.get(0).value()); + + // Parameter + assertEquals(RawTokenType.WORD, tokens.get(2).type()); + assertEquals("VALUE", tokens.get(2).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(3).type()); + assertEquals("=", tokens.get(3).value()); + + assertEquals(RawTokenType.WORD, tokens.get(4).type()); + assertEquals("42", tokens.get(4).value()); + + assertEquals(RawTokenType.EOF, tokens.get(5).type()); + } + + @Test + void testCommandWithStringParameter() { + List tokens = Tokenizer.tokenize("GREET MSG='Hello World'"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GREET", tokens.get(0).value()); + + // First parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("MSG", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("Hello World", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testCommandWithMultilineStringParameter() { + List tokens = Tokenizer.tokenize("GREET MSG='Hello\nWorld'"); + assertEquals(5, tokens.size()); + + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("GREET", tokens.get(0).value()); + + // First parameter + assertEquals(RawTokenType.WORD, tokens.get(1).type()); + assertEquals("MSG", tokens.get(1).value()); + + assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type()); + assertEquals("=", tokens.get(2).value()); + + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("Hello\nWorld", tokens.get(3).value()); + + assertEquals(RawTokenType.EOF, tokens.get(4).type()); + } + + @Test + void testWhitespace() { + List tokens = Tokenizer.tokenize(" \t PING"); + assertEquals(2, tokens.size()); + assertEquals(RawTokenType.WORD, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + } + + @Test + void testStringWithEscapedQuote() { + List tokens = Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'"); + assertEquals(5, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("it's a wonderful day", tokens.get(3).value()); + } + + @Test + void testStringWithWronglyEscapedQuote() { + TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'")); + assertEquals("Unterminated string literal", ex.getMessage()); + } + + @Test + void testStringWithEscapedBackslash() { + List tokens = Tokenizer.tokenize("EXECUTE_COMMAND CMD='\\whoami'"); + assertEquals(5, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(3).type()); + assertEquals("\\whoami", tokens.get(3).value()); + } + + @Test + void testColumnTracking() { + List tokens = Tokenizer.tokenize("GET VAL=1"); + assertEquals(5, tokens.size()); + assertEquals(1, tokens.get(0).column()); + assertEquals(5, tokens.get(1).column()); + assertEquals(8, tokens.get(2).column()); + assertEquals(9, tokens.get(3).column()); + } + + @Test + void testUnterminatedStringThrows() { + TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed")); + assertEquals(1, ex.getLine()); + assertEquals(12, ex.getColumn()); + assertTrue(ex.getMessage().contains("Unterminated string literal")); + } + + @Test + void testUnexpectedCharacterThrows() { + TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE")); + assertTrue(ex.getMessage().contains("Unexpected character '@'")); + assertEquals(1, ex.getLine()); + } + + @Test + void testEmptyStringValue() { + List tokens = Tokenizer.tokenize("cmd = ''"); + assertEquals(4, tokens.size()); + assertEquals(RawTokenType.STRING, tokens.get(2).type()); + assertEquals("", tokens.get(2).value()); + } +} From 4bfb59f234b54de0a2c2a14bae85ca8822cd710d Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 25 Apr 2026 13:28:10 +0200 Subject: [PATCH 4/8] Test: Unit tests for TokenClassifier Co-authored-by: Copilot --- .../parser/tokenizer/TokenClassifierTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java new file mode 100644 index 0000000..9a3dc21 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java @@ -0,0 +1,85 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class TokenClassifierTest { + @Test + void testClassifySimpleCommand() { + List raw = Tokenizer.tokenize("PING"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(2, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals("PING", tokens.get(0).value()); + assertEquals(TokenType.EOF, tokens.get(1).type()); + } + + @Test + void testCommandWithParameter() { + List raw = Tokenizer.tokenize("ANSWER VALUE=42"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(5, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals(TokenType.KEY, tokens.get(1).type()); + assertEquals(TokenType.SEPARATOR, tokens.get(2).type()); + assertEquals(TokenType.VALUE, tokens.get(3).type()); + assertEquals("42", tokens.get(3).value()); + assertEquals(TokenType.EOF, tokens.get(4).type()); + } + + @Test + void testStringValue() { + List raw = Tokenizer.tokenize("GREET MSG='Hello World'"); + List tokens = TokenClassifier.classify(raw); + + assertEquals(5, tokens.size()); + assertEquals(TokenType.COMMAND, tokens.get(0).type()); + assertEquals(TokenType.KEY, tokens.get(1).type()); + assertEquals(TokenType.SEPARATOR, tokens.get(2).type()); + assertEquals(TokenType.VALUE, tokens.get(3).type()); + assertEquals("Hello World", tokens.get(3).value()); + } + + @Test + void testUnexpectedStringLiteralThrows() { + List raw = Tokenizer.tokenize("CMD 'oops'"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertTrue(ex.getMessage().contains("Unexpected string literal")); + } + + @Test + void testMissingValueAfterSeparatorThrows() { + List raw = Tokenizer.tokenize("CMD KEY="); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected VALUE after '='", ex.getMessage()); + } + + @Test + void testNextWordIsKeyThrows() { + List raw = Tokenizer.tokenize("CMD KEY1=KEY2=42"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected VALUE after '='", ex.getMessage()); + } + + @Test + void testEmptyRawTokensThrows() { + List raw = new ArrayList<>(); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected COMMAND as first token", ex.getMessage()); + assertEquals(1, ex.getLine()); + assertEquals(1, ex.getColumn()); + } +} From 2190fccd8f990e9de0bc75b0cc35b93058ef80fb Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 25 Apr 2026 13:28:34 +0200 Subject: [PATCH 5/8] Style: Apply Spotless --- .../protocol/parser/tokenizer/TokenizerTest.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java index 5c5a2d3..bd5e486 100644 --- a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenizerTest.java @@ -149,7 +149,8 @@ class TokenizerTest { @Test void testStringWithEscapedQuote() { - List tokens = Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'"); + List tokens = + Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'"); assertEquals(5, tokens.size()); assertEquals(RawTokenType.STRING, tokens.get(3).type()); assertEquals("it's a wonderful day", tokens.get(3).value()); @@ -157,7 +158,10 @@ class TokenizerTest { @Test void testStringWithWronglyEscapedQuote() { - TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'")); + TokenizerException ex = + assertThrows( + TokenizerException.class, + () -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'")); assertEquals("Unterminated string literal", ex.getMessage()); } @@ -181,7 +185,9 @@ class TokenizerTest { @Test void testUnterminatedStringThrows() { - TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed")); + TokenizerException ex = + assertThrows( + TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed")); assertEquals(1, ex.getLine()); assertEquals(12, ex.getColumn()); assertTrue(ex.getMessage().contains("Unterminated string literal")); @@ -189,7 +195,8 @@ class TokenizerTest { @Test void testUnexpectedCharacterThrows() { - TokenizerException ex = assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE")); + TokenizerException ex = + assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE")); assertTrue(ex.getMessage().contains("Unexpected character '@'")); assertEquals(1, ex.getLine()); } From 89fd6aef05a990f553b63fec1f4f625be963c496 Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 25 Apr 2026 14:26:22 +0200 Subject: [PATCH 6/8] Fix: Detect missing key when validating separator --- .../network/protocol/parser/tokenizer/TokenClassifier.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java index 330dcbb..2545fc3 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifier.java @@ -48,7 +48,7 @@ public class TokenClassifier { } private static void validateSeparator(List rawTokens, int index) { - boolean missingKey = index == 0 || rawTokens.get(index - 1).type() != RawTokenType.WORD; + boolean missingKey = index <= 1 || rawTokens.get(index - 1).type() != RawTokenType.WORD; boolean missingValue = index + 1 >= rawTokens.size() From 572b66889da91e6c33e0ef6d3f6fa6a9086e019d Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sat, 25 Apr 2026 14:27:42 +0200 Subject: [PATCH 7/8] Test: Add test for missing key before separator in TokenClassifier --- .../protocol/parser/tokenizer/TokenClassifierTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java index 9a3dc21..8c8748f 100644 --- a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/parser/tokenizer/TokenClassifierTest.java @@ -63,6 +63,15 @@ public class TokenClassifierTest { assertEquals("Expected VALUE after '='", ex.getMessage()); } + @Test + void testMissingKeyBeforeSeparatorThrows() { + List raw = Tokenizer.tokenize("CMD =VALUE"); + + TokenizerException ex = + assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw)); + assertEquals("Expected KEY before '='", ex.getMessage()); + } + @Test void testNextWordIsKeyThrows() { List raw = Tokenizer.tokenize("CMD KEY1=KEY2=42"); From a7e4411cd06c8e9e56d14fe087707f1de3913cc6 Mon Sep 17 00:00:00 2001 From: Lars Simon Winzer Date: Sun, 26 Apr 2026 17:13:11 +0200 Subject: [PATCH 8/8] Test: Add unit tests for RequestParameterAccessor --- .../RequestParameterAccessorTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/request/accessor/RequestParameterAccessorTest.java diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/request/accessor/RequestParameterAccessorTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/request/accessor/RequestParameterAccessorTest.java new file mode 100644 index 0000000..e290293 --- /dev/null +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/network/protocol/request/accessor/RequestParameterAccessorTest.java @@ -0,0 +1,115 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class RequestParameterAccessorTest { + @Test + void testRequiredParameterPresent() { + List parameters = List.of(new RequestParameter("ARG1", "VAL1")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals("VAL1", accessor.require("ARG1")); + } + + @Test + void testMissingRequiredParameterThrows() { + List parameters = List.of(); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + MissingParameterException ex = + assertThrows(MissingParameterException.class, () -> accessor.require("ARG1")); + assertEquals("Required parameter with key 'ARG1' is missing.", ex.getMessage()); + assertEquals("ARG1", ex.getParameterKey()); + } + + @Test + void testRequiredParameterSuccessfulParsing() { + List parameters = List.of(new RequestParameter("ARG1", "42")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals(42, (int) accessor.require("ARG1", Integer::valueOf)); + } + + @Test + void testRequiredParameterInvalidParsingThrows() { + List parameters = + List.of(new RequestParameter("ARG1", "The answer is: 42")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + ParameterParseException ex = + assertThrows( + ParameterParseException.class, + () -> accessor.require("ARG1", Integer::valueOf)); + assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage()); + assertEquals("ARG1", ex.getParameterKey()); + } + + @Test + void testOptionalParameterPresent() { + List parameters = List.of(new RequestParameter("ARG1", "VAL1")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals("VAL1", accessor.optional("ARG1", "DEFAULT")); + } + + @Test + void testOptionalParameterMissing() { + List parameters = List.of(); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals("DEFAULT", accessor.optional("ARG1", "DEFAULT")); + } + + @Test + void testOptionalParameterSuccessfulParsing() { + List parameters = List.of(new RequestParameter("ARG1", "42")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals(42, (int) accessor.optional("ARG1", 7411, Integer::valueOf)); + } + + @Test + void testMissingOptionalParameterSuccessfulParsing() { + List parameters = List.of(); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertEquals(7411, (int) accessor.optional("ARG1", 7411, Integer::valueOf)); + } + + @Test + void testOptionalParameterInvalidParsingThrows() { + List parameters = + List.of(new RequestParameter("ARG1", "The answer is: 42")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + ParameterParseException ex = + assertThrows( + ParameterParseException.class, + () -> accessor.optional("ARG1", 7411, Integer::valueOf)); + assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage()); + assertEquals("ARG1", ex.getParameterKey()); + } + + @Test + void testContainsExistingKey() { + List parameters = List.of(new RequestParameter("ARG1", "VAL1")); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertTrue(accessor.containsKey("ARG1")); + } + + @Test + void testContainsMissingKey() { + List parameters = List.of(); + RequestParameterAccessor accessor = new RequestParameterAccessor(parameters); + + assertFalse(accessor.containsKey("ARG1")); + } +}