From 0bef7ced5aa52230d1a32f53df05e6cd136c2ad0 Mon Sep 17 00:00:00 2001 From: "Andrei.Ovcharenko" Date: Wed, 10 Jun 2026 09:17:22 +0300 Subject: [PATCH] Implement v1.1 quality improvements --- .github/dependabot.yml | 13 +++ .github/workflows/ci.yml | 25 +++++ CHANGELOG.md | 8 ++ README.en.md | 43 ++++++-- README.md | 45 ++++++-- build.gradle.kts | 27 ++--- docs/architecture.md | 45 ++++++++ docs/images/gui-client.svg | 18 ++++ gradle/libs.versions.toml | 22 ++++ .../NetworkChatIntegrationTest.java | 56 ++++++++-- .../networkchat/client/BotChatClient.java | 29 ++--- .../networkchat/client/ChatClient.java | 38 +++++-- .../client/ClientGuiController.java | 7 +- .../networkchat/client/ConsoleChatClient.java | 8 +- .../networkchat/client/gui/ChatWindow.java | 65 +++++++++--- .../networkchat/network/ChatServer.java | 100 ++++++++++++------ .../networkchat/network/ChatServerConfig.java | 75 +++++++++++++ .../networkchat/protocol/ChatMessage.java | 11 +- .../networkchat/ChatProtocolTest.java | 20 ++++ .../networkchat/client/BotChatClientTest.java | 32 ++++++ .../network/ChatServerConfigTest.java | 72 +++++++++++++ 21 files changed, 650 insertions(+), 109 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/images/gui-client.svg create mode 100644 gradle/libs.versions.toml create mode 100644 src/main/java/dev/krotname/networkchat/network/ChatServerConfig.java create mode 100644 src/test/java/dev/krotname/networkchat/client/BotChatClientTest.java create mode 100644 src/test/java/dev/krotname/networkchat/network/ChatServerConfigTest.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92a2504..693cf30 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,15 @@ updates: day: "monday" time: "10:00" open-pull-requests-limit: 5 + groups: + gradle-quality-stack: + patterns: + - "com.github.spotbugs*" + - "org.junit*" + - "org.awaitility*" + jackson: + patterns: + - "com.fasterxml.jackson*" - package-ecosystem: "github-actions" directory: "/" schedule: @@ -14,3 +23,7 @@ updates: day: "monday" time: "10:30" open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20b9d74..5fb9564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,31 @@ jobs: gradle-version: "8.10.2" - name: Run tests and checks run: ./gradlew check jacocoAllReport --stacktrace + - name: Publish coverage summary + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + report="build/reports/jacoco/jacocoAllReport/jacocoAllReport.csv" + awk -F, ' + NR > 1 { + branchMissed += $6 + branchCovered += $7 + lineMissed += $8 + lineCovered += $9 + } + END { + lineTotal = lineMissed + lineCovered + branchTotal = branchMissed + branchCovered + lineCoverage = lineTotal == 0 ? 100 : (lineCovered * 100 / lineTotal) + branchCoverage = branchTotal == 0 ? 100 : (branchCovered * 100 / branchTotal) + print "### JaCoCo coverage" + print "" + print "| Metric | Coverage |" + print "| --- | ---: |" + printf "| Lines | %.2f%% |\n", lineCoverage + printf "| Branches | %.2f%% |\n", branchCoverage + } + ' "$report" >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage report if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3921659..2e65ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Added `ChatServerConfig` with server limits and socket timeout settings. +- Made server client handling bounded and explicit about busy rejections. +- Split protocol text payloads from display formatting: `data` is raw text, `sender` is the author. +- Fixed GUI connection lifecycle by preserving the base client status update path. +- Added architecture documentation, grouped Dependabot updates, version catalog, and CI coverage summary. + ## 1.0.0 - Reworked project into Gradle Java 21 multi-layer architecture. diff --git a/README.en.md b/README.en.md index fa4489b..8c20568 100644 --- a/README.en.md +++ b/README.en.md @@ -3,7 +3,7 @@ [![CI](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml/badge.svg)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) [![CodeQL](https://github.com/krotname/JavaNetworkChat/actions/workflows/codeql.yml/badge.svg)](https://github.com/krotname/JavaNetworkChat/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/krotname/JavaNetworkChat/badge)](https://securityscorecards.dev/viewer/?uri=github.com/krotname/JavaNetworkChat) -[![coverage](https://img.shields.io/badge/coverage-70%2B-green)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) +[![coverage summary](https://img.shields.io/badge/coverage-CI%20summary-blue)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) [![Java](https://img.shields.io/badge/Java-21-007396)](https://adoptium.net/) [![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -17,6 +17,8 @@ Network Chat is a Java 21 chat application over TCP sockets with: - a Swing GUI client using MVC style structure. - a production-like project layout with Gradle, tests, and CI. +![Swing GUI client](docs/images/gui-client.svg) + ## Run locally ### Prerequisites @@ -34,12 +36,27 @@ Network Chat is a Java 21 chat application over TCP sockets with: Server and clients can also be run directly with Java by building jars from Gradle. +The default server port is `1500`. Programmatic server startup can use `ChatServerConfig` to set the +port, maximum client count, handshake timeout, and post-handshake read timeout. + +## Architecture and protocol + +The compact architecture contract is documented in [docs/architecture.md](docs/architecture.md). + +- `ChatServer` accepts TCP connections and handles clients in a bounded executor. +- `ChatConnection` reads and writes one-line UTF-8 JSON frames. +- `ChatProtocol` serializes `ChatMessage`. +- For `TEXT` messages, `data` contains only raw text and `sender` contains the author. +- Console and Swing clients format display text such as `alice: hello`. +- The bot client reads date/time commands from `data` and uses the author from `sender`. + ## Project structure - `src/main/java` — core application classes. - `src/test/java` — unit tests. - `src/integrationTest/java` — protocol and network integration tests. - `src/uiTest/java` — Swing smoke tests. +- `docs` — architecture notes and visual assets. - `.github/workflows` — CI, CodeQL and Scorecard workflows. ## Testing @@ -51,12 +68,14 @@ Server and clients can also be run directly with Java by building jars from Grad - `./gradlew jacocoTestCoverageVerification` - `./gradlew jacocoAllReport` (CI artifact source) -Coverage thresholds are enforced in Gradle and CI. +Coverage thresholds are enforced in Gradle and CI. The HTML JaCoCo report is uploaded as a CI +artifact, and line/branch coverage is published to the GitHub Actions Summary for the Linux job. ### Test strategy -- **Unit tests** (`src/test/java`) check protocol and UI model invariants. -- **Integration tests** (`src/integrationTest/java`) exercise full server/client socket flow with multiple peers. +- **Unit tests** (`src/test/java`) check protocol, bot command handling, and UI model invariants. +- **Integration tests** (`src/integrationTest/java`) exercise full server/client socket flow, handshake + failures, resource limits, timeouts, and multiple peers. - **UI smoke tests** (`src/uiTest/java`) verify Swing state rendering. - **Future hardening tests**: contract validation and error-handling matrix can be added in the same structure. @@ -68,9 +87,9 @@ The repository runs: - `checkstyle` for style and API cleanliness, - `spotless` for deterministic formatting, - `spotbugs` for bug-pattern analysis, -- `jaCoCo` line/branch coverage gate on core network/protocol layers (`70%/55%`), +- `jaCoCo` line/branch coverage gate on core network/protocol layers (`80%/65%`), - GitHub Actions pipeline on Linux + Windows, -- dependency and workflow update signals via Dependabot, +- grouped dependency and workflow update signals via Dependabot, - CodeQL and OpenSSF Scorecard security scans. The quality surface is intentionally structured for a public review: clean `main` surface, automated checks, @@ -98,3 +117,15 @@ This repository is organized to be review-friendly: - Security checks via CodeQL and OpenSSF Scorecard. - Dependency and workflow automation via Dependabot. - Explicit contributor and security docs. + +## Troubleshooting + +- `Address already in use`: run the server on another port, for example `./gradlew runServer --args="--port 1600"`. +- GUI does not render in CI: UI smoke tests skip automatically in headless environments. +- Client disconnects immediately: check username uniqueness and nickname length (`3..64`, letters, digits, `_`, `-`). +- Client receives `Server is busy`: the configured `ChatServerConfig.maxClients` limit has been reached. + +## Roadmap + +- v1.1.x: stabilize protocol/server lifecycle, expand negative tests, and improve documentation. +- Later: rooms, message history, TLS, and persistent accounts as separate product-focused phases. diff --git a/README.md b/README.md index 2b622f5..f47615d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml/badge.svg)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) [![CodeQL](https://github.com/krotname/JavaNetworkChat/actions/workflows/codeql.yml/badge.svg)](https://github.com/krotname/JavaNetworkChat/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/krotname/JavaNetworkChat/badge)](https://securityscorecards.dev/viewer/?uri=github.com/krotname/JavaNetworkChat) -[![coverage](https://img.shields.io/badge/coverage-70%2B-green)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) +[![coverage summary](https://img.shields.io/badge/coverage-CI%20summary-blue)](https://github.com/krotname/JavaNetworkChat/actions/workflows/ci.yml) [![Java](https://img.shields.io/badge/Java-21-007396)](https://adoptium.net/) [![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -19,6 +19,8 @@ Network Chat — это Java 21 приложение для сетевого ч - GUI клиент на Swing с разделением на MVC; - структуру продакшн-проекта с Gradle, тестами и CI. +![Swing GUI client](docs/images/gui-client.svg) + ## Запуск ```bash @@ -28,6 +30,21 @@ Network Chat — это Java 21 приложение для сетевого ч ./gradlew runGuiClient ``` +Сервер по умолчанию слушает порт `1500`. Для программного запуска используйте +`ChatServerConfig`: он задаёт порт, максимальное число клиентов, timeout handshake и timeout чтения +после handshake. + +## Архитектура и протокол + +Краткий архитектурный контракт описан в [docs/architecture.md](docs/architecture.md). + +- `ChatServer` принимает TCP-соединения и обрабатывает клиентов в bounded executor. +- `ChatConnection` читает и пишет однострочные UTF-8 JSON frames. +- `ChatProtocol` сериализует `ChatMessage`. +- Для `TEXT` сообщений `data` содержит только исходный текст, а `sender` содержит автора. +- Console и Swing клиенты сами форматируют отображение вида `alice: hello`. +- Bot client отвечает на команды времени/даты по `data`, используя автора из `sender`. + ## Тесты и качество - `./gradlew test` @@ -37,14 +54,24 @@ Network Chat — это Java 21 приложение для сетевого ч - `./gradlew jacocoAllReport` - `./gradlew jacocoTestCoverageVerification` +В CI HTML-отчёт JaCoCo публикуется как artifact, а line/branch coverage добавляется в GitHub +Actions Summary для Linux job. + ## Стратегия тестирования -- **Unit-тесты** (`src/test/java`) — протокол и модель GUI. -- **Интеграционные тесты** (`src/integrationTest/java`) — подключение нескольких клиентов к серверу и обмен сообщениями. +- **Unit-тесты** (`src/test/java`) — протокол, bot-команды, модель GUI. +- **Интеграционные тесты** (`src/integrationTest/java`) — подключение клиентов, handshake, лимиты сервера, timeout и обмен сообщениями. - **UI smoke тесты** (`src/uiTest/java`) — проверка отрисовки состояния окна чата. -- **План роста** — контракты протокола и матрицы негативных сценариев (подключение дубликатов, некорректные пакеты). +- **План роста** — больше негативных сценариев протокола и проверок отказоустойчивости медленных клиентов. + +Для оценки покрытия используется JaCoCo: в CI порог для ядра (`network` + `protocol`) — `80%/65%` (`line`/`branch`). + +## Troubleshooting -Для оценки покрытия используется JaCoCo: в CI порог для ядра (`network` + `protocol`) — `70%/55%` (`line`/`branch`). +- `Address already in use`: запустите сервер на другом порту, например `./gradlew runServer --args="--port 1600"`. +- GUI не показывает окно в CI: UI smoke тесты автоматически пропускаются в headless окружении. +- Клиент сразу отключился: проверьте уникальность имени и длину ника (`3..64`, буквы, цифры, `_`, `-`). +- Клиент получил `Server is busy`: достигнут `maxClients` из `ChatServerConfig`. ## Структура репозитория @@ -52,6 +79,7 @@ Network Chat — это Java 21 приложение для сетевого ч - `src/test/java` — unit-тесты. - `src/integrationTest/java` — интеграционные тесты. - `src/uiTest/java` — smoke тесты UI. +- `docs` — архитектурные заметки и визуальные материалы. - `.github/workflows` — CI и проверки безопасности. ## Дополнительные сигналы качества @@ -59,5 +87,10 @@ Network Chat — это Java 21 приложение для сетевого ч - CI на Linux и Windows. - Авто-проверки: Checkstyle, Spotless, SpotBugs, JaCoCo. - Security проверки: CodeQL и OpenSSF Scorecard. -- Dependabot для обновлений зависимостей и Actions. +- Dependabot с группировкой обновлений зависимостей и Actions. - Явно оформленные файлы `CONTRIBUTING.md` и `SECURITY.md`. + +## Roadmap + +- v1.1.x: стабилизация protocol/server lifecycle, расширение негативных тестов, улучшение документации. +- Позже: комнаты, история сообщений, TLS и персистентные аккаунты отдельными product-focused этапами. diff --git a/build.gradle.kts b/build.gradle.kts index ce15b07..e15cf9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,13 +2,13 @@ plugins { java application jacoco - id("com.diffplug.spotless") version "6.25.0" + alias(libs.plugins.spotless) id("checkstyle") - id("com.github.spotbugs") version "6.0.12" + alias(libs.plugins.spotbugs) } group = "dev.krotname" -version = "1.0.0" +version = "1.1.0-SNAPSHOT" java { toolchain { @@ -21,17 +21,17 @@ repositories { } dependencies { - implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") - compileOnly("com.github.spotbugs:spotbugs-annotations:4.8.6") + implementation(libs.jackson.databind) + compileOnly(libs.spotbugs.annotations) - testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") - testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.2") - testImplementation("org.awaitility:awaitility:4.2.1") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.awaitility) + testRuntimeOnly(libs.junit.platform.launcher) } checkstyle { - toolVersion = "10.12.5" + toolVersion = libs.versions.checkstyle.get() configFile = file("config/checkstyle/checkstyle.xml") } @@ -119,6 +119,7 @@ tasks.register("jacocoAllReport") { ) reports { xml.required.set(true) + csv.required.set(true) html.required.set(true) } } @@ -128,7 +129,7 @@ tasks.withType().configureEach { } jacoco { - toolVersion = "0.8.12" + toolVersion = libs.versions.jacoco.get() } tasks.named("jacocoTestCoverageVerification") { @@ -157,12 +158,12 @@ tasks.named("jacocoTestCoverageVerification") { limit { counter = "LINE" value = "COVEREDRATIO" - minimum = "0.70".toBigDecimal() + minimum = "0.80".toBigDecimal() } limit { counter = "BRANCH" value = "COVEREDRATIO" - minimum = "0.55".toBigDecimal() + minimum = "0.65".toBigDecimal() } } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..67f0c46 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,45 @@ +# Network Chat Architecture + +Network Chat is a small Java 21 TCP chat application with a deliberately simple runtime shape: + +```text +clients <-> ChatConnection <-> ChatProtocol <-> ChatServer +``` + +## Runtime Flow + +1. `ChatServer` opens a `ServerSocket` on the configured port. +2. Each accepted socket is handled by a bounded client executor. +3. The server sends `NAME_REQUEST`, waits for `USER_NAME`, validates uniqueness, then responds with + `NAME_ACCEPTED`. +4. Existing users are sent to the new client as `USER_ADDED` events. +5. `TEXT` messages are broadcast to other clients with raw text in `data` and the author in + `sender`. +6. Closing or failed connections are removed and announced with `USER_REMOVED`. + +## Message Frames + +Frames are one-line UTF-8 JSON objects serialized by `ChatProtocol`. + +- `type` is required for every frame. +- `data` is optional for control frames, but required and non-blank for `TEXT`. +- `sender` carries the author for `TEXT`; clients own display formatting. +- `timestamp` and `messageId` are generated when a frame is created. +- `data` is limited by `ChatMessage.MAX_DATA_LENGTH`. + +## Server Limits + +`ChatServerConfig` centralizes runtime limits: + +- `port` - TCP port, default `1500`. +- `maxClients` - maximum concurrent client handler threads, default `100`. +- `handshakeTimeout` - maximum time to complete username registration, default `10s`. +- `readTimeout` - idle socket read timeout after handshake, default `5m`. + +The legacy `new ChatServer(int port)` constructor delegates to `ChatServerConfig.ofPort(port)`. + +## Client Model + +`ChatClient` owns the socket lifecycle and exposes hooks for console, bot, and Swing clients. +Connection status is updated through a final template method before client-specific UI or console +side effects run, so subclasses cannot skip the shared latch/status update. diff --git a/docs/images/gui-client.svg b/docs/images/gui-client.svg new file mode 100644 index 0000000..804fa6b --- /dev/null +++ b/docs/images/gui-client.svg @@ -0,0 +1,18 @@ + + Network Chat Swing client + Screenshot-style diagram of the Swing chat client window. + + + + Network Chat + + Type a message and press Enter + + + alice: hello + bob: hi there + date_bot_128: Information for alice: 2026 + alice + bob + date_bot_128 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8a00ba7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +[versions] +awaitility = "4.2.1" +checkstyle = "10.12.5" +jackson = "2.17.2" +jacoco = "0.8.12" +junit = "5.10.2" +junit-platform = "1.10.2" +spotbugs = "6.0.12" +spotbugs-annotations = "4.8.6" +spotless = "6.25.0" + +[libraries] +awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } +spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs-annotations" } + +[plugins] +spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugs" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/src/integrationTest/java/dev/krotname/networkchat/NetworkChatIntegrationTest.java b/src/integrationTest/java/dev/krotname/networkchat/NetworkChatIntegrationTest.java index 10dd4ea..e53e031 100644 --- a/src/integrationTest/java/dev/krotname/networkchat/NetworkChatIntegrationTest.java +++ b/src/integrationTest/java/dev/krotname/networkchat/NetworkChatIntegrationTest.java @@ -1,10 +1,12 @@ package dev.krotname.networkchat; 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 dev.krotname.networkchat.network.ChatConnection; import dev.krotname.networkchat.network.ChatServer; +import dev.krotname.networkchat.network.ChatServerConfig; import dev.krotname.networkchat.protocol.ChatMessage; import dev.krotname.networkchat.protocol.MessageType; import java.io.IOException; @@ -34,7 +36,7 @@ void serverBroadcastsMessagesBetweenClients() throws Exception { assertTrue(server.getConnectedUsers().contains("bob")); alice.sendText("Привет всем"); - awaitTextContains(bob, "alice: Привет всем"); + awaitTextMessage(bob, "alice", "Привет всем"); } } } @@ -103,6 +105,46 @@ void serverRemovesUserWhenConnectionCloses() throws Exception { } } + @Test + void serverRejectsConnectionsOverConfiguredLimit() throws Exception { + int port = randomFreePort(); + ChatServerConfig config = + new ChatServerConfig(port, 1, Duration.ofSeconds(2), Duration.ofSeconds(5)); + try (ChatServer server = new ChatServer(config)) { + server.start(); + server.awaitStarted(); + + try (TestClient alice = new TestClient("alice", port)) { + alice.connect(); + Socket rejectedSocket = new Socket("127.0.0.1", port); + rejectedSocket.setSoTimeout((int) TimeUnit.SECONDS.toMillis(2)); + try (ChatConnection rejected = new ChatConnection(rejectedSocket)) { + ChatMessage response = rejected.receive(); + assertEquals(MessageType.ERROR, response.type()); + assertTrue(response.data().contains("busy")); + } + } + } + } + + @Test + void serverClosesClientThatDoesNotCompleteHandshake() throws Exception { + int port = randomFreePort(); + ChatServerConfig config = + new ChatServerConfig(port, 2, Duration.ofMillis(200), Duration.ofSeconds(5)); + try (ChatServer server = new ChatServer(config)) { + server.start(); + server.awaitStarted(); + + Socket socket = new Socket("127.0.0.1", port); + socket.setSoTimeout((int) TimeUnit.SECONDS.toMillis(2)); + try (ChatConnection silent = new ChatConnection(socket)) { + assertEquals(MessageType.NAME_REQUEST, silent.receive().type()); + assertThrows(IOException.class, silent::receive); + } + } + } + private static void consumeNameRequest(ChatConnection connection) throws IOException { ChatMessage request; do { @@ -111,19 +153,21 @@ private static void consumeNameRequest(ChatConnection connection) throws IOExcep && request.type() != MessageType.NAME_ACCEPTED); } - private static void awaitTextContains(TestClient client, String expected) { + private static void awaitTextMessage( + TestClient client, String expectedSender, String expectedData) { Awaitility.await("wait for broadcast") .pollInterval(Duration.ofMillis(200)) .atMost(Duration.ofSeconds(5)) - .untilAsserted(() -> assertTrue(containsInQueue(client, expected))); + .untilAsserted(() -> assertTrue(containsTextMessage(client, expectedSender, expectedData))); } - private static boolean containsInQueue(TestClient client, String expected) { + private static boolean containsTextMessage( + TestClient client, String expectedSender, String expectedData) { ChatMessage message; while ((message = client.drainQueue()) != null) { if (MessageType.TEXT.equals(message.type()) - && message.data() != null - && message.data().contains(expected)) { + && expectedSender.equals(message.sender()) + && expectedData.equals(message.data())) { return true; } } diff --git a/src/main/java/dev/krotname/networkchat/client/BotChatClient.java b/src/main/java/dev/krotname/networkchat/client/BotChatClient.java index c133f4c..0300e30 100644 --- a/src/main/java/dev/krotname/networkchat/client/BotChatClient.java +++ b/src/main/java/dev/krotname/networkchat/client/BotChatClient.java @@ -1,11 +1,13 @@ package dev.krotname.networkchat.client; +import dev.krotname.networkchat.protocol.ChatMessage; import java.io.IOException; import java.time.Clock; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; @@ -67,13 +69,20 @@ private static Map createCommands() { return Collections.unmodifiableMap(map); } - private void answerCommand(String sender, String command) { + String answerForCommand(ChatMessage message) { + if (message == null + || message.sender() == null + || message.sender().isBlank() + || message.data() == null) { + return null; + } + String command = message.data().trim().toLowerCase(Locale.ROOT); DateTimeFormatter formatter = COMMANDS.get(command); if (formatter == null) { - return; + return null; } String answer = LocalDateTime.now(clock).format(formatter); - sendTextMessage(String.format("Информация для %s: %s", sender, answer)); + return String.format("Информация для %s: %s", message.sender(), answer); } private final class BotSocketThread extends SocketThread { @@ -87,17 +96,11 @@ protected void clientHandshake() throws IOException { } @Override - protected void processIncomingMessage(String message) { - if (message == null || !message.contains(": ")) { - return; - } - String[] chunks = message.split(": ", 2); - if (chunks.length != 2) { - return; + protected void processIncomingMessage(ChatMessage message) { + String answer = answerForCommand(message); + if (answer != null) { + sendTextMessage(answer); } - String sender = chunks[0]; - String command = chunks[1].trim().toLowerCase(); - answerCommand(sender, command); } @Override diff --git a/src/main/java/dev/krotname/networkchat/client/ChatClient.java b/src/main/java/dev/krotname/networkchat/client/ChatClient.java index 0d70f55..0fcf0fe 100644 --- a/src/main/java/dev/krotname/networkchat/client/ChatClient.java +++ b/src/main/java/dev/krotname/networkchat/client/ChatClient.java @@ -60,15 +60,18 @@ public void run() { return; } if (shouldSendTextFromConsole()) { - while (clientConnected) { - String text = readInputLine(); - if (text == null || EXIT_COMMAND.equalsIgnoreCase(text)) { - break; + try { + while (clientConnected) { + String text = readInputLine(); + if (text == null || EXIT_COMMAND.equalsIgnoreCase(text)) { + break; + } + sendTextMessage(text); } - sendTextMessage(text); + } finally { + closeConnection(); } } - closeConnection(); } protected String readInputLine() { @@ -166,15 +169,15 @@ protected void clientMainLoop() throws IOException { switch (type) { case USER_ADDED -> informAboutAddingNewUser(message.data()); case USER_REMOVED -> informAboutDeletingNewUser(message.data()); - case TEXT -> processIncomingMessage(message.data()); + case TEXT -> processIncomingMessage(message); case ERROR -> LOG.warning("Server error: " + message.data()); default -> throw new IOException("Unexpected message type: " + type); } } } - protected void processIncomingMessage(String message) { - System.out.println(message); + protected void processIncomingMessage(ChatMessage message) { + System.out.println(formatTextMessage(message)); } protected void informAboutAddingNewUser(String userName) { @@ -185,8 +188,23 @@ protected void informAboutDeletingNewUser(String userName) { System.out.printf("Участник вышел: %s%n", userName); } - protected void notifyConnectionStatusChanged(boolean clientConnected) { + /** + * Updates the shared connection state before client-specific hooks run. Subclasses should + * override {@link #onConnectionStatusChanged(boolean)} for UI or console side effects. + */ + protected final void notifyConnectionStatusChanged(boolean clientConnected) { setConnectionStatus(clientConnected); + onConnectionStatusChanged(clientConnected); + } + + protected void onConnectionStatusChanged(boolean clientConnected) {} + + protected final String formatTextMessage(ChatMessage message) { + String sender = message.sender(); + if (sender == null || sender.isBlank()) { + return message.data(); + } + return String.format("%s: %s", sender, message.data()); } } } diff --git a/src/main/java/dev/krotname/networkchat/client/ClientGuiController.java b/src/main/java/dev/krotname/networkchat/client/ClientGuiController.java index 64a9316..0c7a587 100644 --- a/src/main/java/dev/krotname/networkchat/client/ClientGuiController.java +++ b/src/main/java/dev/krotname/networkchat/client/ClientGuiController.java @@ -2,6 +2,7 @@ import dev.krotname.networkchat.client.gui.ChatWindow; import dev.krotname.networkchat.client.gui.ClientGuiModel; +import dev.krotname.networkchat.protocol.ChatMessage; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** GUI-specific client controller. */ @@ -62,8 +63,8 @@ public void dispose() { justification = "Inner thread intentionally uses outer controller for UI callbacks.") public final class GuiSocketThread extends SocketThread { @Override - protected void processIncomingMessage(String message) { - model.setNewMessage(message); + protected void processIncomingMessage(ChatMessage message) { + model.setNewMessage(formatTextMessage(message)); view.refreshMessages(); } @@ -80,7 +81,7 @@ protected void informAboutDeletingNewUser(String userName) { } @Override - protected void notifyConnectionStatusChanged(boolean clientConnected) { + protected void onConnectionStatusChanged(boolean clientConnected) { view.notifyConnectionStatusChanged(clientConnected); } } diff --git a/src/main/java/dev/krotname/networkchat/client/ConsoleChatClient.java b/src/main/java/dev/krotname/networkchat/client/ConsoleChatClient.java index edf5568..de754d1 100644 --- a/src/main/java/dev/krotname/networkchat/client/ConsoleChatClient.java +++ b/src/main/java/dev/krotname/networkchat/client/ConsoleChatClient.java @@ -1,5 +1,6 @@ package dev.krotname.networkchat.client; +import dev.krotname.networkchat.protocol.ChatMessage; import dev.krotname.networkchat.util.ConsoleInput; import java.io.IOException; @@ -35,8 +36,8 @@ protected SocketThread getSocketThread() { private final class ConsoleSocketThread extends SocketThread { @Override - protected void processIncomingMessage(String message) { - System.out.println(message); + protected void processIncomingMessage(ChatMessage message) { + System.out.println(formatTextMessage(message)); } @Override @@ -50,8 +51,7 @@ protected void informAboutDeletingNewUser(String userName) { } @Override - protected void notifyConnectionStatusChanged(boolean clientConnected) { - super.notifyConnectionStatusChanged(clientConnected); + protected void onConnectionStatusChanged(boolean clientConnected) { if (clientConnected) { System.out.println("Соединение с сервером установлено"); } else { diff --git a/src/main/java/dev/krotname/networkchat/client/gui/ChatWindow.java b/src/main/java/dev/krotname/networkchat/client/gui/ChatWindow.java index 43712c3..9d2450c 100644 --- a/src/main/java/dev/krotname/networkchat/client/gui/ChatWindow.java +++ b/src/main/java/dev/krotname/networkchat/client/gui/ChatWindow.java @@ -5,6 +5,8 @@ import java.awt.BorderLayout; import java.awt.event.ActionEvent; import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import javax.swing.JFrame; import javax.swing.JOptionPane; import javax.swing.JScrollPane; @@ -19,10 +21,10 @@ public final class ChatWindow { justification = "Controller lifecycle is intentionally bound to the view lifecycle.") private final ClientGuiController controller; - private final JFrame frame = new JFrame("Network Chat"); - private final JTextField messageField = new JTextField(50); - private final JTextArea messages = new JTextArea(10, 50); - private final JTextArea users = new JTextArea(10, 10); + private JFrame frame; + private JTextField messageField; + private JTextArea messages; + private JTextArea users; public ChatWindow(ClientGuiController controller) { this(controller, true); @@ -30,10 +32,15 @@ public ChatWindow(ClientGuiController controller) { public ChatWindow(ClientGuiController controller, boolean visible) { this.controller = controller; - init(visible); + runOnEdt(() -> init(visible)); } private void init(boolean visible) { + frame = new JFrame("Network Chat"); + messageField = new JTextField(50); + messages = new JTextArea(10, 50); + users = new JTextArea(10, 10); + messages.setEditable(false); users.setEditable(false); messageField.setEditable(false); @@ -54,34 +61,45 @@ private void init(boolean visible) { } public String getServerAddress() { - return JOptionPane.showInputDialog( - frame, "Введите адрес сервера:", "Конфигурация", JOptionPane.QUESTION_MESSAGE); + return callOnEdt( + () -> + JOptionPane.showInputDialog( + frame, "Введите адрес сервера:", "Конфигурация", JOptionPane.QUESTION_MESSAGE)); } public int getServerPort() { while (true) { String input = - JOptionPane.showInputDialog( - frame, "Введите порт сервера:", "Конфигурация", JOptionPane.QUESTION_MESSAGE); + callOnEdt( + () -> + JOptionPane.showInputDialog( + frame, + "Введите порт сервера:", + "Конфигурация", + JOptionPane.QUESTION_MESSAGE)); if (input == null || input.isBlank()) { continue; } try { return Integer.parseInt(input.trim()); } catch (NumberFormatException e) { - JOptionPane.showMessageDialog( - frame, "Некорректный порт", "Ошибка", JOptionPane.ERROR_MESSAGE); + runOnEdt( + () -> + JOptionPane.showMessageDialog( + frame, "Некорректный порт", "Ошибка", JOptionPane.ERROR_MESSAGE)); } } } public String getUserName() { - return JOptionPane.showInputDialog( - frame, "Введите имя:", "Конфигурация", JOptionPane.QUESTION_MESSAGE); + return callOnEdt( + () -> + JOptionPane.showInputDialog( + frame, "Введите имя:", "Конфигурация", JOptionPane.QUESTION_MESSAGE)); } public void notifyConnectionStatusChanged(boolean clientConnected) { - messageField.setEditable(clientConnected); + runOnEdt(() -> messageField.setEditable(clientConnected)); SwingUtilities.invokeLater( () -> JOptionPane.showMessageDialog( @@ -126,14 +144,27 @@ private void runOnEdt(Runnable action) { } public String getMessagesText() { - return messages.getText(); + return callOnEdt(messages::getText); } public String getUsersText() { - return users.getText(); + return callOnEdt(users::getText); + } + + public boolean isMessageInputEditable() { + return callOnEdt(messageField::isEditable); } public void dispose() { - frame.dispose(); + runOnEdt(frame::dispose); + } + + private T callOnEdt(Supplier action) { + if (SwingUtilities.isEventDispatchThread()) { + return action.get(); + } + AtomicReference result = new AtomicReference<>(); + runOnEdt(() -> result.set(action.get())); + return result.get(); } } diff --git a/src/main/java/dev/krotname/networkchat/network/ChatServer.java b/src/main/java/dev/krotname/networkchat/network/ChatServer.java index 59adefc..ca317c3 100644 --- a/src/main/java/dev/krotname/networkchat/network/ChatServer.java +++ b/src/main/java/dev/krotname/networkchat/network/ChatServer.java @@ -8,11 +8,17 @@ import java.util.Collections; import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; @@ -21,34 +27,35 @@ /** Chat server that authenticates users, keeps sessions, and broadcasts messages. */ public final class ChatServer implements AutoCloseable { private static final Logger LOG = Logger.getLogger(ChatServer.class.getName()); - private static final int DEFAULT_PORT = 1500; private static final int MIN_USER_NAME_LENGTH = 3; private static final int MAX_USER_NAME_LENGTH = 64; - private final int port; + private final ChatServerConfig config; private final Map sessions = new ConcurrentHashMap<>(); private final Object sessionsMonitor = new Object(); private final AtomicInteger activeClients = new AtomicInteger(); - private final ExecutorService clientExecutor = - Executors.newCachedThreadPool( - r -> { - Thread t = new Thread(r, "chat-client-handler"); - t.setDaemon(true); - return t; - }); + private final ExecutorService clientExecutor; private final ExecutorService acceptorExecutor = - Executors.newSingleThreadExecutor( - r -> { - Thread t = new Thread(r, "chat-acceptor"); - t.setDaemon(true); - return t; - }); + Executors.newSingleThreadExecutor(daemonThreadFactory("chat-acceptor")); private final AtomicBoolean running = new AtomicBoolean(false); private final CountDownLatch startSignal = new CountDownLatch(1); private volatile ServerSocket serverSocket; public ChatServer(int port) { - this.port = port; + this(ChatServerConfig.ofPort(port)); + } + + public ChatServer(ChatServerConfig config) { + this.config = Objects.requireNonNull(config, "config"); + this.clientExecutor = + new ThreadPoolExecutor( + 0, + config.maxClients(), + 30L, + TimeUnit.SECONDS, + new SynchronousQueue<>(), + daemonThreadFactory("chat-client-handler"), + new ThreadPoolExecutor.AbortPolicy()); } public static void main(String[] args) throws Exception { @@ -67,14 +74,14 @@ private static int parsePort(String[] args) { if (args.length == 1) { return Integer.parseInt(args[0]); } - return DEFAULT_PORT; + return ChatServerConfig.DEFAULT_PORT; } public void start() throws IOException { if (!running.compareAndSet(false, true)) { return; } - serverSocket = new ServerSocket(port); + serverSocket = new ServerSocket(config.port()); acceptorExecutor.execute(this::acceptLoop); startSignal.countDown(); } @@ -91,7 +98,7 @@ private void acceptLoop() { while (running.get()) { try { Socket socket = serverSocket.accept(); - clientExecutor.execute(() -> handleClient(socket)); + submitClient(socket); } catch (IOException ex) { if (running.get()) { LOG.log(Level.WARNING, "Accept failed", ex); @@ -100,6 +107,23 @@ private void acceptLoop() { } } + private void submitClient(Socket socket) { + try { + clientExecutor.execute(() -> handleClient(socket)); + } catch (RejectedExecutionException ex) { + rejectClient(socket, "Server is busy, try again later"); + } + } + + private void rejectClient(Socket socket, String reason) { + try (socket; + ChatConnection connection = new ChatConnection(socket)) { + connection.send(ChatMessage.withData(MessageType.ERROR, reason, null)); + } catch (IOException ex) { + LOG.log(Level.FINE, "Unable to send rejection to client", ex); + } + } + /** * Processes a single client socket from acceptance to logout and ensures active-client metrics * are always accurate through finally block cleanup. @@ -107,14 +131,18 @@ private void acceptLoop() { private void handleClient(Socket socket) { String userName = null; boolean active = false; - try (socket; - ChatConnection connection = new ChatConnection(socket)) { - userName = serverHandshake(connection); - sendUsersListToNewClient(connection, userName); - activeClients.incrementAndGet(); - active = true; - broadcast(ChatMessage.withData(MessageType.USER_ADDED, userName, null), connection); - serverMainLoop(connection, userName); + try { + socket.setSoTimeout(config.handshakeTimeoutMillis()); + try (socket; + ChatConnection connection = new ChatConnection(socket)) { + userName = serverHandshake(connection); + socket.setSoTimeout(config.readTimeoutMillis()); + sendUsersListToNewClient(connection, userName); + activeClients.incrementAndGet(); + active = true; + broadcast(ChatMessage.withData(MessageType.USER_ADDED, userName, null), connection); + serverMainLoop(connection, userName); + } } catch (IOException | RuntimeException ex) { LOG.log(Level.FINE, "Client session ended", ex); } finally { @@ -182,8 +210,7 @@ private void serverMainLoop(ChatConnection connection, String userName) throws I while (running.get()) { ChatMessage message = connection.receive(); if (message.type() == MessageType.TEXT) { - String text = message.data() == null ? "" : message.data(); - broadcast(ChatMessage.text(String.format("%s: %s", userName, text), userName), connection); + broadcast(ChatMessage.text(message.data(), userName), connection); continue; } if (message.type() == MessageType.NAME_ACCEPTED @@ -256,7 +283,12 @@ public Set getConnectedUsers() { } public int getPort() { - return port; + ServerSocket socket = serverSocket; + return socket == null ? config.port() : socket.getLocalPort(); + } + + public ChatServerConfig getConfig() { + return config; } public boolean isRunning() { @@ -266,4 +298,12 @@ public boolean isRunning() { public int getActiveClients() { return activeClients.get(); } + + private static ThreadFactory daemonThreadFactory(String threadName) { + return task -> { + Thread thread = new Thread(task, threadName); + thread.setDaemon(true); + return thread; + }; + } } diff --git a/src/main/java/dev/krotname/networkchat/network/ChatServerConfig.java b/src/main/java/dev/krotname/networkchat/network/ChatServerConfig.java new file mode 100644 index 0000000..1efeff5 --- /dev/null +++ b/src/main/java/dev/krotname/networkchat/network/ChatServerConfig.java @@ -0,0 +1,75 @@ +package dev.krotname.networkchat.network; + +import java.time.Duration; +import java.util.Objects; + +/** Runtime limits and socket timeouts for {@link ChatServer}. */ +public record ChatServerConfig( + int port, int maxClients, Duration handshakeTimeout, Duration readTimeout) { + public static final int DEFAULT_PORT = 1500; + public static final int DEFAULT_MAX_CLIENTS = 100; + public static final Duration DEFAULT_HANDSHAKE_TIMEOUT = Duration.ofSeconds(10); + public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofMinutes(5); + + public ChatServerConfig { + if (port < 0 || port > 65_535) { + throw new IllegalArgumentException("Port must be in range 0..65535"); + } + if (maxClients < 1) { + throw new IllegalArgumentException("Max clients must be positive"); + } + Objects.requireNonNull(handshakeTimeout, "handshakeTimeout"); + Objects.requireNonNull(readTimeout, "readTimeout"); + if (handshakeTimeout.isNegative()) { + throw new IllegalArgumentException("Handshake timeout must not be negative"); + } + if (readTimeout.isNegative()) { + throw new IllegalArgumentException("Read timeout must not be negative"); + } + validateSocketTimeout(handshakeTimeout, "handshakeTimeout"); + validateSocketTimeout(readTimeout, "readTimeout"); + } + + public static ChatServerConfig defaultConfig() { + return new ChatServerConfig( + DEFAULT_PORT, DEFAULT_MAX_CLIENTS, DEFAULT_HANDSHAKE_TIMEOUT, DEFAULT_READ_TIMEOUT); + } + + public static ChatServerConfig ofPort(int port) { + return defaultConfig().withPort(port); + } + + public ChatServerConfig withPort(int newPort) { + return new ChatServerConfig(newPort, maxClients, handshakeTimeout, readTimeout); + } + + int handshakeTimeoutMillis() { + return toSocketTimeoutMillis(handshakeTimeout); + } + + int readTimeoutMillis() { + return toSocketTimeoutMillis(readTimeout); + } + + private static void validateSocketTimeout(Duration timeout, String fieldName) { + toSocketTimeoutMillis(timeout, fieldName); + } + + private static int toSocketTimeoutMillis(Duration timeout) { + return toSocketTimeoutMillis(timeout, "timeout"); + } + + private static int toSocketTimeoutMillis(Duration timeout, String fieldName) { + if (timeout.isZero()) { + return 0; + } + long millis = timeout.toMillis(); + if (millis <= 0) { + return 1; + } + if (millis > Integer.MAX_VALUE) { + throw new IllegalArgumentException(fieldName + " is too large for socket timeout"); + } + return (int) millis; + } +} diff --git a/src/main/java/dev/krotname/networkchat/protocol/ChatMessage.java b/src/main/java/dev/krotname/networkchat/protocol/ChatMessage.java index 2364ca1..f3cc283 100644 --- a/src/main/java/dev/krotname/networkchat/protocol/ChatMessage.java +++ b/src/main/java/dev/krotname/networkchat/protocol/ChatMessage.java @@ -4,7 +4,13 @@ import java.util.Objects; import java.util.UUID; -/** Immutable message object used by both client and server. */ +/** + * Immutable message object used by both client and server. + * + *

For {@link MessageType#TEXT}, {@code data} contains only the raw chat text and {@code sender} + * contains the user name. Clients are responsible for presentation formatting such as {@code + * "alice: hello"}. + */ public record ChatMessage( MessageType type, String data, String sender, long timestamp, String messageId) { public static final int MAX_DATA_LENGTH = 2048; @@ -14,6 +20,9 @@ public record ChatMessage( if (data != null && data.length() > MAX_DATA_LENGTH) { throw new IllegalArgumentException("Message data is too long"); } + if (type == MessageType.TEXT && (data == null || data.isBlank())) { + throw new IllegalArgumentException("Text message data is required"); + } } public static ChatMessage withData(MessageType type, String data, String sender) { diff --git a/src/test/java/dev/krotname/networkchat/ChatProtocolTest.java b/src/test/java/dev/krotname/networkchat/ChatProtocolTest.java index bca0062..940c561 100644 --- a/src/test/java/dev/krotname/networkchat/ChatProtocolTest.java +++ b/src/test/java/dev/krotname/networkchat/ChatProtocolTest.java @@ -19,6 +19,13 @@ void roundTripTextMessage() throws IOException { assertEquals(message, restored); } + @Test + void textMessageKeepsSenderOutOfPayload() { + ChatMessage message = ChatMessage.text("hello", "alice"); + assertEquals("hello", message.data()); + assertEquals("alice", message.sender()); + } + @Test void rejectsOverlongPayload() { String overlong = "a".repeat(ChatMessage.MAX_DATA_LENGTH + 1); @@ -27,6 +34,19 @@ void rejectsOverlongPayload() { () -> ChatMessage.withData(MessageType.TEXT, overlong, "alice")); } + @Test + void rejectsBlankTextPayload() { + assertThrows(IllegalArgumentException.class, () -> ChatMessage.text(null, "alice")); + assertThrows(IllegalArgumentException.class, () -> ChatMessage.text(" ", "alice")); + assertThrows( + IOException.class, + () -> + ChatProtocol.decode( + """ + {"type":"TEXT","data":"","sender":"alice","timestamp":1,"messageId":"id"} + """)); + } + @Test void decodeRejectsInvalidFrame() { assertThrows(IOException.class, () -> ChatProtocol.decode("{\"broken\":true}")); diff --git a/src/test/java/dev/krotname/networkchat/client/BotChatClientTest.java b/src/test/java/dev/krotname/networkchat/client/BotChatClientTest.java new file mode 100644 index 0000000..da54dc6 --- /dev/null +++ b/src/test/java/dev/krotname/networkchat/client/BotChatClientTest.java @@ -0,0 +1,32 @@ +package dev.krotname.networkchat.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.krotname.networkchat.protocol.ChatMessage; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +class BotChatClientTest { + + @Test + void answersCommandUsingStructuredSender() { + BotChatClient bot = + new BotChatClient(Clock.fixed(Instant.parse("2026-06-10T12:34:56Z"), ZoneId.of("UTC"))); + + String answer = bot.answerForCommand(ChatMessage.text("год", "alice")); + + assertEquals("Информация для alice: 2026", answer); + } + + @Test + void ignoresUnknownOrSenderlessCommands() { + BotChatClient bot = + new BotChatClient(Clock.fixed(Instant.parse("2026-06-10T12:34:56Z"), ZoneId.of("UTC"))); + + assertNull(bot.answerForCommand(ChatMessage.text("unknown", "alice"))); + assertNull(bot.answerForCommand(ChatMessage.text("год", null))); + } +} diff --git a/src/test/java/dev/krotname/networkchat/network/ChatServerConfigTest.java b/src/test/java/dev/krotname/networkchat/network/ChatServerConfigTest.java new file mode 100644 index 0000000..22edd4f --- /dev/null +++ b/src/test/java/dev/krotname/networkchat/network/ChatServerConfigTest.java @@ -0,0 +1,72 @@ +package dev.krotname.networkchat.network; + +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 java.time.Duration; +import org.junit.jupiter.api.Test; + +class ChatServerConfigTest { + + @Test + void defaultAndPortSpecificConfigAreValid() { + ChatServerConfig defaults = ChatServerConfig.defaultConfig(); + ChatServerConfig configuredPort = ChatServerConfig.ofPort(0); + + assertEquals(ChatServerConfig.DEFAULT_PORT, defaults.port()); + assertEquals(ChatServerConfig.DEFAULT_MAX_CLIENTS, defaults.maxClients()); + assertEquals(0, configuredPort.port()); + assertEquals(ChatServerConfig.DEFAULT_MAX_CLIENTS, configuredPort.maxClients()); + } + + @Test + void convertsSocketTimeoutsForServerUse() { + ChatServerConfig disabledTimeouts = new ChatServerConfig(1500, 1, Duration.ZERO, Duration.ZERO); + ChatServerConfig roundedTimeouts = + new ChatServerConfig(1500, 1, Duration.ofNanos(1), Duration.ofNanos(1)); + + assertEquals(0, disabledTimeouts.handshakeTimeoutMillis()); + assertEquals(0, disabledTimeouts.readTimeoutMillis()); + assertEquals(1, roundedTimeouts.handshakeTimeoutMillis()); + assertEquals(1, roundedTimeouts.readTimeoutMillis()); + } + + @Test + void rejectsInvalidLimitsAndTimeouts() { + assertThrows( + IllegalArgumentException.class, + () -> new ChatServerConfig(-1, 1, Duration.ZERO, Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> new ChatServerConfig(65_536, 1, Duration.ZERO, Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> new ChatServerConfig(1500, 0, Duration.ZERO, Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> new ChatServerConfig(1500, 1, Duration.ofMillis(-1), Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> new ChatServerConfig(1500, 1, Duration.ZERO, Duration.ofMillis(-1))); + assertThrows( + NullPointerException.class, () -> new ChatServerConfig(1500, 1, null, Duration.ZERO)); + assertThrows( + NullPointerException.class, () -> new ChatServerConfig(1500, 1, Duration.ZERO, null)); + assertThrows( + IllegalArgumentException.class, + () -> + new ChatServerConfig( + 1500, 1, Duration.ofMillis((long) Integer.MAX_VALUE + 1L), Duration.ZERO)); + } + + @Test + void legacyServerConstructorKeepsConfigAccessible() { + ChatServer server = new ChatServer(0); + + assertEquals(0, server.getPort()); + assertEquals(0, server.getConfig().port()); + assertFalse(server.isRunning()); + server.close(); + } +}