✨ Add basic MSTA client
authorDavid Roden <github-a8in@qsheltier.de>
Sat, 25 Jan 2025 09:18:20 +0000 (10:18 +0100)
committerDavid Roden <github-a8in@qsheltier.de>
Sat, 25 Jan 2025 09:32:35 +0000 (10:32 +0100)
client/pom.xml
client/src/main/java/de/qsheltier/msta/client/Client.java [new file with mode: 0644]
client/src/test/java/de/qsheltier/msta/client/ClientTest.java [new file with mode: 0644]
client/src/test/java/de/qsheltier/msta/client/FakeTcpServer.java [new file with mode: 0644]
client/src/test/java/de/qsheltier/msta/client/TextSocket.java [new file with mode: 0644]
pom.xml

index 4cd574c..758aef7 100644 (file)
        <name>MSTA – Client</name>
        <description>Manual Software Testing Avoidance – Client Component</description>
 
+       <dependencies>
+               <dependency>
+                       <groupId>com.fasterxml.jackson.core</groupId>
+                       <artifactId>jackson-databind</artifactId>
+               </dependency>
+       </dependencies>
+
 </project>
diff --git a/client/src/main/java/de/qsheltier/msta/client/Client.java b/client/src/main/java/de/qsheltier/msta/client/Client.java
new file mode 100644 (file)
index 0000000..2830534
--- /dev/null
@@ -0,0 +1,73 @@
+package de.qsheltier.msta.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * An MSTA {@code Client} connects to an MSTA server, can send commands,
+ * and notifies listeners of received events.
+ */
+public class Client {
+
+       public Client(String hostname, int port) {
+               this.hostname = hostname;
+               this.port = port;
+               socket = new Socket();
+       }
+
+       public void onConnect(Runnable connectListener) {
+               connectListeners.add(connectListener);
+       }
+
+       public void connect() throws IOException {
+               socket.connect(new InetSocketAddress(hostname, port));
+               outputWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), UTF_8));
+               new Thread(() -> {
+                       try (Socket socket = this.socket) {
+                               handleConnection(socket.getInputStream());
+                       } catch (IOException e) {
+                               /* ignore. */
+                       }
+               }).start();
+       }
+
+       public void sendCommand(String command) throws IOException {
+               outputWriter.write(command + "\r\n");
+               outputWriter.flush();
+       }
+
+       private void handleConnection(InputStream inputStream) throws IOException {
+               try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, UTF_8))) {
+                       while (true) {
+                               var line = reader.readLine();
+                               if (line == null) {
+                                       break;
+                               }
+                               var reply = new ObjectMapper().readTree(line);
+                               if (reply.has("event") && reply.get("event").asText().equalsIgnoreCase("connected")) {
+                                       connectListeners.forEach(Runnable::run);
+                               }
+                       }
+               }
+       }
+
+       private final Socket socket;
+       private Writer outputWriter;
+       private final String hostname;
+       private final int port;
+
+       private final List<Runnable> connectListeners = new ArrayList<>();
+
+}
diff --git a/client/src/test/java/de/qsheltier/msta/client/ClientTest.java b/client/src/test/java/de/qsheltier/msta/client/ClientTest.java
new file mode 100644 (file)
index 0000000..6053bbd
--- /dev/null
@@ -0,0 +1,56 @@
+package de.qsheltier.msta.client;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Timeout.ThreadMode.SEPARATE_THREAD;
+
+public class ClientTest {
+
+       @Test
+       @Timeout(value = 5, unit = SECONDS)
+       public void clientCanConnectToAServer() throws Exception {
+               var client = new Client("localhost", server.getPort());
+               client.connect();
+               server.connect().get();
+       }
+
+       @Test
+       @Timeout(value = 5, unit = SECONDS, threadMode = SEPARATE_THREAD)
+       public void listenerIsNotifiedOnConnectEvent() throws Exception {
+               var client = new Client("localhost", server.getPort());
+               var connectListenerCalled = new AtomicBoolean(false);
+               client.onConnect(() -> connectListenerCalled.set(true));
+               client.connect();
+               server.connect().get();
+               server.writeLine("{\"event\": \"connected\"}");
+               while (!connectListenerCalled.get()) {
+                       Thread.yield();
+               }
+       }
+
+       @Test
+       @Timeout(value = 5, unit = SECONDS, threadMode = SEPARATE_THREAD)
+       public void clientCanSendCommandToServer() throws Exception {
+               var client = new Client("localhost", server.getPort());
+               client.connect();
+               server.connect().get();
+               server.writeLine("{\"event\": \"connected\"}");
+               client.sendCommand("shutdown");
+               var line = server.readLine();
+               assertThat(line, equalTo("shutdown"));
+       }
+
+       public ClientTest() throws IOException {
+       }
+
+       private final FakeTcpServer server = new FakeTcpServer(Executors.newCachedThreadPool());
+
+}
diff --git a/client/src/test/java/de/qsheltier/msta/client/FakeTcpServer.java b/client/src/test/java/de/qsheltier/msta/client/FakeTcpServer.java
new file mode 100644 (file)
index 0000000..d4d94b1
--- /dev/null
@@ -0,0 +1,52 @@
+package de.qsheltier.msta.client;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+import org.hamcrest.Matcher;
+
+public class FakeTcpServer implements Closeable {
+
+       private final ExecutorService executorService;
+       private final ServerSocket serverSocket = new ServerSocket(0);
+       private final AtomicReference<TextSocket> clientSocket = new AtomicReference<>();
+
+       public FakeTcpServer(ExecutorService executorService) throws IOException {
+               this.executorService = executorService;
+       }
+
+       public int getPort() {
+               return serverSocket.getLocalPort();
+       }
+
+       public Future<?> connect() {
+               return executorService.submit(() -> {
+                       try {
+                               clientSocket.set(new TextSocket(serverSocket.accept()));
+                       } catch (IOException e) {
+                               throw new RuntimeException(e);
+                       }
+               });
+       }
+
+       public void writeLine(String... lines) throws IOException {
+               var textSocket = clientSocket.get();
+               for (String line : lines) {
+                       textSocket.writeLine(line);
+               }
+       }
+
+       public String readLine() throws IOException {
+               return clientSocket.get().readLine();
+       }
+
+       @Override
+       public void close() throws IOException {
+               clientSocket.get().close();
+       }
+
+}
diff --git a/client/src/test/java/de/qsheltier/msta/client/TextSocket.java b/client/src/test/java/de/qsheltier/msta/client/TextSocket.java
new file mode 100644 (file)
index 0000000..dc2019b
--- /dev/null
@@ -0,0 +1,48 @@
+package de.qsheltier.msta.client;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+import org.hamcrest.Matcher;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Wrapper around a [Socket] that handles text.
+ */
+public class TextSocket implements Closeable {
+
+       private final Socket socket;
+       private final BufferedReader inputReader;
+       private final Writer outputWriter;
+
+       public TextSocket(Socket socket) throws IOException {
+               this.socket = socket;
+               inputReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
+               outputWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), UTF_8));
+       }
+
+       public String readLine() throws IOException {
+               return inputReader.readLine();
+       }
+
+       public void writeLine(String line) throws IOException {
+               outputWriter.write(line + "\r\n");
+               outputWriter.flush();
+       }
+
+       @Override
+       public void close() throws IOException {
+               outputWriter.close();
+               inputReader.close();
+               socket.close();
+       }
+
+}
diff --git a/pom.xml b/pom.xml
index 7584c79..ecfc5b6 100644 (file)
--- a/pom.xml
+++ b/pom.xml
                                <version>3.0</version>
                                <scope>test</scope>
                        </dependency>
+                       <dependency>
+                               <groupId>com.fasterxml.jackson.core</groupId>
+                               <artifactId>jackson-databind</artifactId>
+                               <version>2.18.1</version>
+                       </dependency>
                </dependencies>
        </dependencyManagement>