Diskussion Unit-Tests für Parametervalidierung - Codereview

Mat

Mitglied
Und auch für diese Woche habe ich wieder alten Code herausgekramt.

Die Aufgabe bestand darin, eine Client-Server-Verbindung zu implementieren, die Befehle per TCP entgegen nimmt und verarbeitet.

Interessanterweise hatte ich da aber wohl mal Tests für implementiert und wollte ein bisschen Feedback dazu holen.

Habe so viel vom Originalcode entfernt wie möglich, sodass nur noch funktionierende UnitTests bleiben und man ungefähr sehen kann, wie das Programm aufgebaut ist. Es ist lauffähig aber Client und ein bisschen Drumherum fehlen. Trotzdem noch recht groß, deswegen auch noch zusätzlich als zip dabei.

Tests

Main
MainTest:
package dev;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class MainTest {

  @Test
  void argsAccept0() {
    assertTrue(Main.argsValidator.test(0));
  }

  @Test
  void argsDenyTooMany() {
    assertFalse(Main.argsValidator.test(Main.REQUIRED_ARGS + 1));
  }

  @Test
  void argsDenyTooFew() {
    assertFalse(Main.argsValidator.test(Main.REQUIRED_ARGS - 1));
  }

  @Test
  void argsAcceptValidNumber() {
    assertTrue(Main.argsValidator.test(Main.REQUIRED_ARGS));
  }
}
Main:
package dev;

import dev.util.ServerValidator;

import java.util.function.Predicate;

class Main {
  private static final boolean NO_ARGS_OK = true;
  static final int REQUIRED_ARGS = 2;

  static final Predicate<Integer> argsValidator = numOfArgs -> numOfArgs == REQUIRED_ARGS || (NO_ARGS_OK && numOfArgs == 0);

  public static void main(String[] args) {
    if (!argsValidator.test(args.length)) {
      System.out.println("\nUngültige Anzahl an Parametern übergeben");
      errorMessage();
      return;
    }

    Server server;
    if (args.length == REQUIRED_ARGS) {
      if (!ServerValidator.isPortValid(args[0]) || !ServerValidator.isPwValid(args[1])) {
        System.out.println("\nUngültige Parameter");
        errorMessage();
        return;
      }
      int port = Integer.parseInt(args[0]);
      String pw = args[1];
      server = new Server(port, pw);
    } else {
      server = new Server();
    }
    server.run();
  }

  private static void errorMessage() {
    System.out.println("Verwendung:");
    System.out.println("xyz.jar [{PORT} {PASSWORT}]");
    System.out.println("------------------------------------------------------------------");
  }
}
Session
SessionTest:
package dev;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.net.Socket;
import java.util.HashMap;
import java.util.Map;

import static dev.util.ServerExceptions.SyntaxError;
import static dev.util.ServerExceptions.UnknownCommand;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class SessionTest {
  private Session session;
  private Map<Command, String> command;

  @BeforeEach
  void setUp() {
    this.session = new Session(new Socket());
    this.command = new HashMap<>();
  }

  @Test
  void denyUnknownCommands() {
    assertThrows(UnknownCommand.class, () -> session.parseCmdFromString("enum existiert nicht"));
  }

  @Test
  void denyWrongCase() {
    assertThrows(UnknownCommand.class, () -> session.parseCmdFromString("bye"));
  }

  @Test
  void byeAccept() throws Exception {
    command.put(Command.BYE, null);
    assertEquals(command, session.parseCmdFromString("BYE"));
  }

  @Test
  void byeDenyParams() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("BYE" + Command.DELIMITER + "text"));
  }

  @Test
  void shutdownAccept() throws Exception {
    command.put(Command.SHUTDOWN, "passwort");
    assertEquals(command, session.parseCmdFromString("SHUTDOWN" + Command.DELIMITER + "passwort"));
  }

  @Test
  void shutdownAcceptDelimiter() throws Exception {
    String msg = "pwteil1" + Command.DELIMITER + "pwteil2" + Command.DELIMITER;
    command.put(Command.SHUTDOWN, msg);
    assertEquals(command, session.parseCmdFromString("SHUTDOWN" + Command.DELIMITER + msg));
  }

  @Test
  void shutdownDeny0Param() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("SHUTDOWN"));
  }

  @Test
  void shutdownDenyEmptyParam() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("SHUTDOWN" + Command.DELIMITER + ""));
  }

  @Test
  void lowercaseAccept() throws Exception {
    command.put(Command.LOWERCASE, "text");
    assertEquals(command, session.parseCmdFromString("LOWERCASE" + Command.DELIMITER + "text"));
  }

  @Test
  void lowercaseAcceptDelimiter() throws Exception {
    String msg = "tEiL1" + Command.DELIMITER + "TeIl2" + Command.DELIMITER;
    command.put(Command.LOWERCASE, msg);
    assertEquals(command, session.parseCmdFromString("LOWERCASE" + Command.DELIMITER + msg));
  }

  @Test
  void lowercaseDeny0Param() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("LOWERCASE"));
  }

  @Test
  void lowercaseDenyEmptyParam() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("LOWERCASE" + Command.DELIMITER + ""));
  }

  @Test
  void uppercaseAccept() throws Exception {
    command.put(Command.UPPERCASE, "text");
    assertEquals(command, session.parseCmdFromString("UPPERCASE" + Command.DELIMITER + "text"));
  }

  @Test
  void uppercaseAcceptDelimiter() throws Exception {
    String msg = "tEiL1" + Command.DELIMITER + "TeIl2" + Command.DELIMITER;
    command.put(Command.UPPERCASE, msg);
    assertEquals(command, session.parseCmdFromString("UPPERCASE" + Command.DELIMITER + msg));
  }

  @Test
  void uppercaseDeny0Params() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("UPPERCASE"));
  }

  @Test
  void uppercaseDenyEmptyParam() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("UPPERCASE" + Command.DELIMITER + ""));
  }

  @Test
  void reverseAccept() throws Exception {
    command.put(Command.REVERSE, "text");
    assertEquals(command, session.parseCmdFromString("REVERSE" + Command.DELIMITER + "text"));
  }

  @Test
  void reverseAcceptDelimiter() throws Exception {
    String msg = "tEiL1" + Command.DELIMITER + "TeIl2" + Command.DELIMITER;
    command.put(Command.REVERSE, msg);
    assertEquals(command, session.parseCmdFromString("REVERSE" + Command.DELIMITER + msg));
  }

  @Test
  void reverseDeny0ParamNot() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("REVERSE"));
  }

  @Test
  void reverseDenyEmptyParam() {
    assertThrows(SyntaxError.class, () -> session.parseCmdFromString("REVERSE" + Command.DELIMITER + ""));
  }
}
Session:
package dev;

import dev.util.ServerExceptions;
import dev.util.ServerValidator;
import dev.util.StringHelper;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

final class Session implements Runnable {

  private final Socket socket;
  private DataInputStream inputStream;
  private DataOutputStream outputStream;
  private boolean logoutTriggered;

  private String threadName;

  private void setThreadName(String threadName) { this.threadName = threadName; }

  private String getThreadName() { return this.threadName; }

  Session(Socket socket) {
    this.socket = socket;
    this.logoutTriggered = false;
  }

  private void endSession() { logoutTriggered = true; }

  private void shutdownServer(String pw) {
    if (Server.attemptShutdown(pw)) {
      try {
        sendMsg("Server wird bald heruntergefahren");
      } catch (IOException e) { /* _ */
      }
    } else {
      try {
        sendMsg("Falsches Passwort: Verbindung wird getrennt, Server läuft weiter");
      } catch (IOException e) { /* _ */
      }
    }
    endSession();
  }

  // Wäre vielleicht besser in einem Builder oder im ServerValidator aufgehoben
  Map<Command, String> parseCmdFromString(String text) throws ServerExceptions.SyntaxError, ServerExceptions.UnknownCommand {

    final Map<Command, String> cmd = new HashMap<>(1);
    String[] input = text.split(Command.DELIMITER, 2);

    Command cmdEnum;
    try {
      cmdEnum = Command.valueOf(input[0]);
    } catch (IllegalArgumentException e) {
      throw new ServerExceptions.UnknownCommand();
    }

    // if-else würde hier reichen, es gibt hier im Grunde nur 2 Fälle
    switch (cmdEnum) {
    case BYE:
      if (input.length != 1) {
        throw new ServerExceptions.SyntaxError("BYE nur ohne Parameter erlaubt");
      }
      cmd.put(cmdEnum, null);
      break;
    case LOWERCASE:
      if (input.length != 2 || input[1].isEmpty()) {
        throw new ServerExceptions.SyntaxError("LOWERCASE: Text benötigt");
      }
    case REVERSE:
      if (input.length != 2 || input[1].isEmpty()) {
        throw new ServerExceptions.SyntaxError("REVERSE: Text benötigt");
      }
    case SHUTDOWN:
      if (input.length != 2 || input[1].isEmpty()) {
        throw new ServerExceptions.SyntaxError("SHUTDOWN: Passwort benötigt");
      }
    case UPPERCASE:
      if (input.length != 2 || input[1].isEmpty()) {
        throw new ServerExceptions.SyntaxError("UPPERCASE: Text benötigt");
      }
    default:
      if (!ServerValidator.PARAM_VALID(cmdEnum, input[1])) {
        throw new ServerExceptions.SyntaxError(
            String.format("Ungültiger Parameter, %s benötigt Text und erlaubt maximal %d Bytes", cmdEnum.name(), ServerValidator.maxParamBytesInCmd(cmdEnum)));
      }

      cmd.put(cmdEnum, input[1]);
    }
    return cmd;
  }

  private String processCmd(Map<Command, String> cmd) throws ServerExceptions.SyntaxError, ServerExceptions.UnknownCommand {
    for (Map.Entry<Command, String> cmdEntry : cmd.entrySet()) {
      switch (cmdEntry.getKey()) {
      case BYE:
        endSession();
        return "OK BYE";
      case SHUTDOWN:
        shutdownServer(cmdEntry.getValue());
        return "OK BYE";
      case LOWERCASE:
        return "OK " + StringHelper.toLowerCase(cmdEntry.getValue());
      case UPPERCASE:
        return "OK " + StringHelper.toUpperCase(cmdEntry.getValue());
      case REVERSE:
        return "OK " + StringHelper.reverseString(cmdEntry.getValue());
      default:
        break;
      }
    }
    throw new ServerExceptions.UnknownCommand();
  }

  private void processRequest(String request) throws IOException {
    try {
      Map<Command, String> parsedRequest = parseCmdFromString(request);
      sendMsg(processCmd(parsedRequest));
    } catch (ServerExceptions.UnknownCommand | ServerExceptions.SyntaxError e) {
      sendMsg(e.getMessage());
    }
  }

  private void greetClient() throws IOException { sendMsg(Command.getCmdInfos()); }

  private void sendMsg(final String msg) throws IOException {
    byte[] msgBytes = msg.getBytes(StandardCharsets.UTF_8);

    for (byte b : msgBytes) {
      outputStream.write(b);
    }
    outputStream.write('\n');
    outputStream.flush();
  }

  private String receiveMsg() throws IOException, ServerExceptions.SyntaxError {
    byte[] inputBuffer = new byte[ServerValidator.BYTES_MAX];

    int offset;
    for (offset = 0; offset < ServerValidator.BYTES_MAX; offset++) {
      byte b = inputStream.readByte();
      inputBuffer[offset] = b;

      if (b == '\n')
        break;

      if (offset >= ServerValidator.BYTES_MAX - 1) {
        System.out.printf(Locale.GERMANY, "[%s] Nachricht zu lang (%s @ %s)\n", Server.currentTime(), getThreadName(), socket.getRemoteSocketAddress());
        throw new ServerExceptions.SyntaxError("Nachricht zu lang oder endet nicht mit \\n!");
      }
    }

    return new String(inputBuffer, 0, offset, StandardCharsets.UTF_8);
  }

  private void initSession(String threadName) throws IOException {
    setThreadName(threadName);

    System.out.printf(Locale.GERMANY, "[%s] Sitzung gestartet (%s @ %s)\n", Server.currentTime(), getThreadName(), socket.getRemoteSocketAddress());

    inputStream = new DataInputStream(socket.getInputStream());
    outputStream = new DataOutputStream(socket.getOutputStream());

    greetClient();
  }

  @Override
  public void run() {
    try {
      initSession(Thread.currentThread().getName());

      Thread clientMessages = new Thread(() -> {
        try {
          while (!Thread.currentThread().isInterrupted()) {
            String msg = receiveMsg();
            System.out.printf("[%s] %s: %s\n", Server.currentTime(), Thread.currentThread().getName(), msg);
            Server.updateHeartbeat(System.currentTimeMillis());
            processRequest(msg);
          }
        } catch (IOException e) {
          System.out.printf("[%s] %s: Verbindung getrennt\n", Server.currentTime(), getThreadName());
          endSession();
        } catch (ServerExceptions.SyntaxError ex) {
          try {
            sendMsg(ex.getMessage());
            sendMsg("Sitzung geschlossen. Neu verbinden");
          } catch (IOException couldNotSendMsg) { /* _ */
          } finally {
            endSession();
          }
        }
      });

      clientMessages.start();

      while (true) {
        if (logoutTriggered) {
          if (clientMessages.isAlive()) {
            clientMessages.interrupt();
          }
          if (!socket.isClosed()) {
            socket.shutdownOutput();
            socket.shutdownInput();
            socket.close();
          }
          return;
        }

        try {
          Thread.sleep(Server.UPDATE_INTERVAL_IN_MS);
        } catch (InterruptedException e) {
          return;
        }
      }
    } catch (IOException e) {
      System.err.println("Kann Sitzung nicht initialisieren");
      e.printStackTrace();
    }
  }
}

ServerValidator
ServerValidatorTest:
package dev.util;

import org.junit.jupiter.api.Test;

import static dev.Command.*;
import static dev.util.ServerValidator.*;
import static dev.util.StringHelper.generateSingleByteString;
import static org.junit.jupiter.api.Assertions.*;

class ServerValidatorTest {
  private static final int DELIMITER_BYTES = StringHelper.countBytesInString(DELIMITER);

  @Test
  void portAcceptValidPort() {
    assertTrue(isPortValid("50000"));
  }

  @Test
  void portAcceptLeading0() {
    assertTrue(isPortValid("050000"));
  }

  @Test
  void portDenyNotANumber() {
    assertFalse(isPortValid("KEINPORT"));
  }

  @Test
  void portDenyTooSmall() {
    assertFalse(isPortValid(String.valueOf(ServerValidator.PORT_MIN - 1)));
  }

  @Test
  void portDenyTooBig() {
    assertFalse(isPortValid(String.valueOf(ServerValidator.PORT_MAX + 1)));
  }

  @Test
  void pwAcceptValidPw() {
    assertTrue(isPwValid("passwort"));
  }

  @Test
  void pwAcceptSpecialChars() {
    assertTrue(isPwValid("_'@€§$%&/()=?\\\"µäöüß!-.é`´?"));
  }

  @Test
  void pwDenyEmpty() {
    assertFalse(isPwValid(""));
  }

  @Test
  void pwDenyNewline() {
    assertFalse(isPwValid("asd\nsd"));
  }

  /* Ich denke bei allen Aufrufen von generateSingleByteString('<CHAR>', <ANZAHL>) wäre es besser:
        - "<Zeichen>".repeat(<ANZAHL>) von String::repeat zu benutzen
        - den Wert einmalig als langen static final String fest zu legen, und dann Substring der nötigen Länge zu holen
        - Wert aus Random mit fixem Seed zu generieren, falls auch mehr Eingaben getestet werden sollen
     */

  @Test
  void pwAcceptMaxLength() {
    assertTrue(isPwValid(generateSingleByteString('a', maxParamBytesInCmd(SHUTDOWN))));
  }

  @Test
  void pwDenyTooLong() {
    assertFalse(isPwValid(generateSingleByteString('A', maxParamBytesInCmd(SHUTDOWN) + 1)));
  }

  /* Ich denke, alle folgenden maxBytes-Tests könnten parametrisiert/dynamisch statt per Hand getestet werden,
    da die Werte von festen Regeln abhängen:
        - ob Parameter erlaubt sind (wenn nicht, dann maxBytes = 0)
        - wie lang der Befehl + Delimiter ist
        - wie viele Bytes insgesamt pro Befehl zulässig sind (hier 255)
     */

  @Test
  void maxBytesBye() {
    assertEquals(0, maxParamBytesInCmd(BYE));
  }

  @Test
  void maxBytesByeDenyParams() {
    assertFalse(PARAM_VALID(BYE, "test"));
  }

  @Test
  void maxBytesShutdown() {
    assertEquals(BYTES_MAX - "SHUTDOWN".length() - DELIMITER_BYTES, maxParamBytesInCmd(SHUTDOWN));
  }

  @Test
  void maxBytesShutdownAcceptMaxLength() {
    assertTrue(PARAM_VALID(SHUTDOWN, generateSingleByteString('a', maxParamBytesInCmd(SHUTDOWN))));
  }

  @Test
  void maxBytesShutdownDenyTooLong() {
    assertFalse(PARAM_VALID(SHUTDOWN, generateSingleByteString('a', maxParamBytesInCmd(SHUTDOWN) + 1)));
  }

  @Test
  void maxBytesLowercase() {
    assertEquals(BYTES_MAX - "LOWERCASE".length() - DELIMITER_BYTES, maxParamBytesInCmd(LOWERCASE));
  }

  @Test
  void maxBytesLowercaseAcceptMaxLength() {
    assertTrue(PARAM_VALID(LOWERCASE, generateSingleByteString('a', maxParamBytesInCmd(LOWERCASE))));
  }

  @Test
  void maxBytesLowercaseDenyTooLong() {
    assertFalse(PARAM_VALID(LOWERCASE, generateSingleByteString('a', maxParamBytesInCmd(LOWERCASE) + 1)));
  }

  @Test
  void maxBytesUppercase() {
    assertEquals(BYTES_MAX - "UPPERCASE".length() - DELIMITER_BYTES, maxParamBytesInCmd(UPPERCASE));
  }

  @Test
  void maxBytesUppercaseAcceptMaxLength() {
    assertTrue(PARAM_VALID(UPPERCASE, generateSingleByteString('a', maxParamBytesInCmd(UPPERCASE))));
  }

  @Test
  void maxBytesUppercaseDenyTooLong() {
    assertFalse(PARAM_VALID(UPPERCASE, generateSingleByteString('a', maxParamBytesInCmd(UPPERCASE) + 1)));
  }

  @Test
  void maxBytesReverse() {
    assertEquals(BYTES_MAX - "REVERSE".length() - DELIMITER_BYTES, maxParamBytesInCmd(REVERSE));
  }

  @Test
  void maxBytesReverseAcceptMaxLength() {
    assertTrue(PARAM_VALID(REVERSE, generateSingleByteString('a', maxParamBytesInCmd(REVERSE))));
  }

  @Test
  void maxBytesReverseDenyTooLong() {
    assertFalse(PARAM_VALID(REVERSE, generateSingleByteString('a', maxParamBytesInCmd(REVERSE) + 1)));
  }
}
ServerValidator:
package dev.util;

import dev.Command;

import java.util.Arrays;
import java.util.function.Predicate;

public final class ServerValidator {

  static final int PORT_MIN = 0;
  static final int PORT_MAX = 65535;
  public static final int BYTES_MAX = 255;
  private static final int[] PORTS_RESERVED = new int[] {0};

  public static boolean isPortValid(String portString) {
    try {
      int port = Integer.parseInt(portString);
      return portValidator.test(port);
    } catch (NumberFormatException e) {
      return false;
    }
  }

  private static final Predicate<Integer> portValidator = port -> {
    if (port < PORT_MIN)
      return false;
    if (port > PORT_MAX)
      return false;
    return Arrays.stream(PORTS_RESERVED).noneMatch(i -> i == port);
  };

  public static boolean isPwValid(String pw) { return pwValidator.test(pw); }

  private static final Predicate<String> pwValidator = pw -> {
    if (pw.isEmpty())
      return false;
    if (pw.contains("\n"))
      return false;
    return StringHelper.countBytesInString(pw) <= maxParamBytesInCmd(Command.SHUTDOWN);
  };

  public static boolean PARAM_VALID(Command cmd, String parameter) {
    if (parameter == null || parameter.isEmpty())
      return false;
    if (cmd.equals(Command.BYE))
      return false;

    return StringHelper.countBytesInString(parameter) <= maxParamBytesInCmd(cmd);
  }

  public static int maxParamBytesInCmd(Command cmd) {
    if (cmd.equals(Command.BYE))
      return 0;
    return BYTES_MAX - StringHelper.countBytesInString(cmd.name()) - StringHelper.countBytesInString(Command.DELIMITER);
  }
}
StringHelper
StringHelperTest:
package dev.util;

import org.junit.jupiter.api.Test;

import static dev.util.ServerExceptions.SyntaxError;
import static dev.util.StringHelper.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class StringHelperTest {

  @Test
  void lowercaseFromLowercase() throws Exception {
    assertEquals("abc", toLowerCase("abc"));
  }

  @Test
  void lowercaseNoEmptyString() {
    assertThrows(SyntaxError.class, () -> toLowerCase(""));
  }

  @Test
  void lowercaseNoNullString() {
    assertThrows(SyntaxError.class, () -> toLowerCase(null));
  }

  @Test
  void lowercaseFrom1ASCII() throws Exception {
    assertEquals("z", toLowerCase("Z"));
  }

  @Test
  void lowercaseFromAlphabet() throws Exception {
    assertEquals("abcdefghijklmnopqrstuvwxyz", toLowerCase("ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
  }

  @Test
  void lowercaseFromSpecialChar() throws Exception {
    assertEquals("!", toLowerCase("!"));
  }

  @Test
  void lowercaseFromMixedCase() throws Exception {
    assertEquals("abcdefg", toLowerCase("AbCdEfG"));
  }

  @Test
  void lowercaseFromMixedChars() throws Exception {
    assertEquals("a!b?c§d&e", toLowerCase("A!B?C§D&E"));
  }

  @Test
  void lowercaseFromSingleUmlaut() throws Exception {
    assertEquals("ä", toLowerCase("Ä"));
  }

  @Test
  void lowercaseFromUmlauts() throws Exception {
    assertEquals("äöü", toLowerCase("ÄÖÜ"));
  }

  @Test
  void lowercaseFromLigature() throws Exception {
    assertEquals("ß", toLowerCase("ß"));
  }

  @Test
  void uppercaseFromUppercase() throws Exception {
    assertEquals("ABC", toUpperCase("ABC"));
  }

  @Test
  void uppercaseNoEmptyString() {
    assertThrows(SyntaxError.class, () -> toUpperCase(""));
  }

  @Test
  void uppercaseNoNullString() {
    assertThrows(SyntaxError.class, () -> toUpperCase(null));
  }

  @Test
  void uppercaseFromSingleASCII() throws Exception {
    assertEquals("Z", toUpperCase("z"));
  }

  @Test
  void uppercaseFromASCIIAlphabet() throws Exception {
    assertEquals("ABCDEFGHIJKLMNOPQRSTUVWXYZ", toUpperCase("abcdefghijklmnopqrstuvwxyz"));
  }

  @Test
  void uppercaseFromSpecialChar() throws Exception {
    assertEquals("!", toUpperCase("!"));
  }

  @Test
  void uppercaseFromMixedCase() throws Exception {
    assertEquals("ABCDEFG", toUpperCase("AbCdEfG"));
  }

  @Test
  void uppercaseFromMixedChars() throws Exception {
    assertEquals("A!B?C§D&E", toUpperCase("a!b?c§d&e"));
  }

  @Test
  void uppercaseFromSingleUmlaut() throws Exception {
    assertEquals("Ä", toUpperCase("ä"));
  }

  @Test
  void uppercaseFromUmlauts() throws Exception {
    assertEquals("ÄÖÜ", toUpperCase("äöü"));
  }

  @Test
  void uppercaseFromLigature() throws Exception {
    assertEquals("GROSS", toUpperCase("groß"));
  }

  @Test
  void reverseSingleChar() throws Exception {
    assertEquals("a", reverseString("a"));
  }

  @Test
  void reverseNoEmptyString() {
    assertThrows(SyntaxError.class, () -> reverseString(""));
  }

  @Test
  void reverseNoNullString() {
    assertThrows(SyntaxError.class, () -> reverseString(null));
  }

  @Test
  void reverseEvenNumberOfLetters() throws Exception {
    assertEquals("dcba", reverseString("abcd"));
  }

  @Test
  void reverseUnevenNumberOfLetters() throws Exception {
    assertEquals("edcba", reverseString("abcde"));
  }

  @Test
  void reverseMixedChars() throws Exception {
    assertEquals("E&D§C?B!A", reverseString("A!B?C§D&E"));
  }

  @Test
  void countBytesInStringMaxLength() {
    assertEquals(255, countBytesInString(generateSingleByteString('x', 255)));
  }

  @Test
  void countBytesInEmptyString() {
    assertEquals(0, countBytesInString(""));
  }

  @Test
  void countBytesInNullString() {
    assertEquals(0, countBytesInString(null));
  }

  @Test
  void countBytesInControlChars() {
    assertEquals(3, countBytesInString("\n\n\n"));
  }

  @Test
  void count2ByteChars() {
    assertEquals(2 * 3, countBytesInString("äöü"));
  }

  @Test
  void count3ByteChars() {
    assertEquals(3 * 3, countBytesInString("☺☻♥"));
  }

  @Test
  void count4ByteChars() {
    assertEquals(4 * 3, countBytesInString("𩸽𩸽𩸽"));
  }

  @Test
  void countBytesMixedSizeChars() {
    assertEquals(1 + 2 + 3 + 4, countBytesInString("aä☺𩸽"));
  }
}
StringHelper:
package dev.util;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;

public class StringHelper {

  public static String toLowerCase(String text) throws ServerExceptions.SyntaxError {
    if (text == null || text.isEmpty())
      throw new ServerExceptions.SyntaxError("Leere Zeichenkette für LOWERCASE");
    return text.toLowerCase(Locale.GERMAN);
  }

  public static String toUpperCase(String text) throws ServerExceptions.SyntaxError {
    if (text == null || text.isEmpty())
      throw new ServerExceptions.SyntaxError("Leere Zeichenkette für UPPERCASE");
    return text.toUpperCase(Locale.GERMAN);
  }

  public static String reverseString(String text) throws ServerExceptions.SyntaxError {
    if (text == null || text.isEmpty())
      throw new ServerExceptions.SyntaxError("Leere Zeichenkette für REVERSE");
    if (text.length() == 1)
      return text;
    return new StringBuilder(text).reverse().toString();
  }

  static int countBytesInString(String input) {
    if (input == null)
      return 0;
    return input.getBytes(StandardCharsets.UTF_8).length;
  }

  // Das hier wird wohl nur von Tests benutzt, also sollte es auch in den Tests stecken
  static String generateSingleByteString(char character, int sizeInBytes) {
    if (sizeInBytes < 1 || String.valueOf(character).getBytes(StandardCharsets.UTF_8).length > 1)
      return "";

    byte[] testBytes = new byte[sizeInBytes];
    Arrays.fill(testBytes, String.valueOf(character).getBytes(StandardCharsets.UTF_8)[0]);

    return new String(testBytes, StandardCharsets.UTF_8);
  }
}
Sonstiges
ServerExceptions:
package dev.util;
public class ServerExceptions extends Exception {
  private ServerExceptions() { /* _ */
  }

  private ServerExceptions(String message) { super("ERROR " + message); }

  public static final class SyntaxError extends ServerExceptions {
    public SyntaxError(String message) { super("SYNTAX ERROR " + message); }
  }

  public static class UnknownCommand extends Exception {
    public UnknownCommand() { super("UNKNOWN COMMAND"); }
  }
}
Command:
package dev;

import dev.util.ServerValidator;

public enum Command {
  BYE("BYE", "Sitzung beenden", null),
  LOWERCASE("LOWERCASE", "Zeichenkette in Kleinbuchstaben umwandeln", String.class),
  REVERSE("REVERSE", "Zeichenkette umkehren", String.class),
  SHUTDOWN("SHUTDOWN", "Sitzung beenden und Server herunterfahren", String.class),
  UPPERCASE("UPPERCASE", "Zeichenkette in Großbuchstaben umwandeln", String.class);

  private final String textValue;
  private final String description;
  private final Class paramType;

  String getDescription() { return description; }

  String getParamTypText() { return paramType == null ? null : paramType.getSimpleName(); }

  String getTextValue() { return textValue; }

  public static final String DELIMITER = " ";

  Command(String value, String description, Class paramType) {
    this.textValue = value;
    this.description = description;
    this.paramType = paramType;
  }

  static String getCmdInfos() {
    StringBuilder builder = new StringBuilder();

    builder.append(String.format("\n%10s\t%10s\n", "Befehl", "Parameter")).append("----------------------------------------------\n");

    for (Command cmd : Command.values()) {
      int maxBytes = ServerValidator.maxParamBytesInCmd(cmd);

      builder.append(String.format("%10s\t <%s%s>\n", cmd.getTextValue(), cmd.getParamTypText(), maxBytes == 0 ? "" : String.format(" (max. %d bytes)", maxBytes)))
          .append(String.format("%10s\t %s", "", cmd.getDescription()))
          .append("\n\n");
    }
    builder.append("----------------------------------------------\n");

    return builder.toString();
  }
}
Server:
package dev;

import dev.util.ServerValidator;

import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class Server implements Runnable {

  private static final int CLIENTS_MAX = 3;
  private static final String PW_DEFAULT = "geheim";
  private static final int PORT_DEFAULT = 50_000;

  private static final int TIMEOUT_IN_MS = 30_000;
  static final long UPDATE_INTERVAL_IN_MS = 200L;
  private static final long DELAY_IN_MS = 225L;

  private static Server instance = null;

  static boolean attemptShutdown(String pw) {
    if (instance == null)
      return false;

    if (instance.isValidPw(pw)) {
      instance.sessionPool.shutdown();
      if (Server.instance.sessionPool.getActiveCount() == 0) {
        Server.instance.sessionPool.shutdownNow();
      }
      return true;
    } else {
      return false;
    }
  }

  static void updateHeartbeat(long currentTimeMillis) { Server.instance.latestHeartbeat = Math.max(currentTimeMillis, Server.instance.latestHeartbeat); }

  private boolean isValidPw(String pw) { return pw.equals(getPw()); }

  static String currentTime() { return SimpleDateFormat.getTimeInstance().format(new Date()); }

  private ThreadPoolExecutor sessionPool;
  private long latestHeartbeat;
  private final String pw;
  private final int port;

  private ServerSocket welcomeSocket;

  private int getPort() { return port; }

  private String getPw() { return pw; }

  Server() {
    this(PORT_DEFAULT, PW_DEFAULT);
    System.out.println("* Starte Server mit Standard-Parametern *");
  }

  Server(int port, String pw) {
    this.port = port;
    this.pw = pw;
    Server.instance = this;
  }

  private void denyConnection(String reason) {
    try {
      denyConnection(welcomeSocket.accept(), reason);
    } catch (IOException ex) { /* _ */
    }
  }

  private void denyConnection(Socket socket, String reason) {
    if (socket == null) {
      denyConnection(reason); // öhh, das ruft eine Methode auf, die wiederum diese Methode aufruft.. gefährlich
      return;
    }

    try (final DataOutputStream outputStream = new DataOutputStream(socket.getOutputStream())) {
      System.out.printf("[%s] Sitzung verweigert für: %s\n", currentTime(), socket.getRemoteSocketAddress());

      byte[] msgBytes = String.format("Verbindung abgelehnt: %s", reason).getBytes(StandardCharsets.UTF_8);
      for (byte b : msgBytes) {
        if (b == '\n' || b == '\r')
          break;
        outputStream.write(b);
      }
      outputStream.write('\n');

      outputStream.flush();
    } catch (IOException ex) { /* _ */
    }
  }

  private void startServer() throws IOException {
    this.welcomeSocket = new ServerSocket();
    welcomeSocket.setReceiveBufferSize(ServerValidator.BYTES_MAX);
    welcomeSocket.bind(new InetSocketAddress(getPort()), 1);
    this.sessionPool = new ThreadPoolExecutor(CLIENTS_MAX, CLIENTS_MAX, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(CLIENTS_MAX));

    System.out.printf("Server gestartet. Port: %d, PW: %s\n", getPort(), getPw());
    System.out.printf("Erlaubte aktive Verbindungen: %d; Erlaubte Warteschlangengröße: %d\n", sessionPool.getCorePoolSize(),
                      sessionPool.getMaximumPoolSize() - sessionPool.getCorePoolSize());
  }

  @Override
  public void run() {
    Thread.currentThread().setName(this.toString());

    try {
      startServer();

      Thread welcomeThread = new Thread(() -> {
        while (true) {
          if (sessionPool.getActiveCount() >= CLIENTS_MAX || sessionPool.isTerminating()) {
            denyConnection("Keine freien Plätze, später versuchen");
          }

          if (sessionPool.getActiveCount() < CLIENTS_MAX && !sessionPool.isTerminating()) {
            Socket socket = null;
            try {
              Thread.sleep(DELAY_IN_MS);
              socket = new Socket();
              socket.setReceiveBufferSize(ServerValidator.BYTES_MAX);
              socket.setSendBufferSize(ServerValidator.BYTES_MAX);
              socket = welcomeSocket.accept();
              Session session = new Session(socket);
              sessionPool.execute(session);
            } catch (SocketException ignore) {
              denyConnection(socket, "Kann keine Verbindung herstellen");
            } catch (RejectedExecutionException ignore) {
              denyConnection(socket, "keine freien Plätze");
            } catch (IOException e) {
              e.printStackTrace();
            } catch (InterruptedException ignore) {
              return;
            }
          }
        }
      });

      welcomeThread.start();

      while (true) {
        if (sessionPool.isTerminated()) {
          if (welcomeThread.isAlive()) {
            welcomeThread.interrupt();
          }

          System.out.println("Keine aktiven Clients mehr, Server wird sofort heruntergefahren...");
          sessionPool.shutdownNow();
          System.exit(0);
        }

        if (sessionPool.isTerminating() || sessionPool.isShutdown()) {
          if (welcomeThread.isAlive() || !welcomeThread.isInterrupted()) {
            System.out.println("Schließe Anmelde-Socket. Es werden keine neuen Verbindungen zugelassen.");
            welcomeThread.interrupt();
          }

          System.out.println("Warte auf Timeout der verbundenen Clients...");
          boolean terminated = false;

          while (!terminated) {
            try {
              terminated = sessionPool.awaitTermination(UPDATE_INTERVAL_IN_MS, TimeUnit.MILLISECONDS);
              if ((System.currentTimeMillis() - latestHeartbeat) > TIMEOUT_IN_MS) {
                if (welcomeThread.isAlive())
                  welcomeThread.interrupt();

                System.out.println("Timeout, Beenden wird erzwungen");
                sessionPool.shutdownNow();
                System.exit(0);
              }

            } catch (InterruptedException e) { /* _ */
            }
          }

          System.out.println("Keine aktiven Sitzungen mehr, Server wird jetzt heruntergefahren...");
          sessionPool.shutdownNow();
          System.exit(0);
        }

        try {
          Thread.sleep(UPDATE_INTERVAL_IN_MS);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
      System.exit(-1);
    }
  }

  @Override
  public String toString() {
    return String.format("Server @ Port %d", getPort());
  }
}
Überlegungen
  1. Wo mir direkt Sachen auffielen habe ich Kommentare im Code hinterlassen
  2. Das war im Original noch JUnit4, habe es in Junit5 umgewandelt, damit es aktueller ist
  3. die statischen Importe für die Tests würde ich normalerweise nicht in dieser Menge benutzen, weil man damit durcheinander kommen könnte
  4. hab da viel per Hand getestet habe; das hätte ich automatisieren können (bietet sich bei der Struktur des Enums gut an)
  5. mit JUnit5 kann man glaube ich ganz gut parametrisiert testen, aber das hab ich mir noch nicht näher angesehen (auch mit JUnit4 wären Schleifen + Callbacks möglich gewesen, statt der vielen manuellen Tests)
  6. wenn ich SessionTest so sehe, scheint die Session-Klasse Aufgaben zu übernehmen, die in ServerValidator oder einer eigenen Klasse besser aufgehoben wären (wenn man den Klassen-Aufbau so lassen will)
 

Anhänge

Zuletzt bearbeitet:
Oben Unten