diff --git a/.gitignore b/.gitignore
index 6ac465db..5e108027 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
target/
/.idea/
+.env
+.env.local
+*.env
\ No newline at end of file
diff --git a/README.md b/README.md
index 5fdc622f..932258d4 100644
--- a/README.md
+++ b/README.md
@@ -16,3 +16,6 @@ A JavaFX-based chat client using [ntfy](https://docs.ntfy.sh/) for backend messa
2. Start with:
```bash
./mvnw clean javafx:run
+
+## Author
+Created for Lab 3 - Network Programming - Younes Ahmad
diff --git a/ntfy-server.yml b/ntfy-server.yml
new file mode 100644
index 00000000..204ac75a
--- /dev/null
+++ b/ntfy-server.yml
@@ -0,0 +1,11 @@
+# ntfy server configuration
+base-url: "http://localhost:8080"
+
+# Enable attachments
+attachment-cache-dir: "/var/cache/ntfy/attachments"
+attachment-total-size-limit: "5G"
+attachment-file-size-limit: "50M"
+attachment-expiry-duration: "3h"
+
+# Logging
+log-level: "info"
\ No newline at end of file
diff --git a/null b/null
new file mode 100644
index 00000000..4c8ccf15
Binary files /dev/null and b/null differ
diff --git a/pom.xml b/pom.xml
index c40f667e..713244ac 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,53 +1,98 @@
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
-
com.example
- javafx
+ javafx-chat-app
1.0-SNAPSHOT
-
- 25
+ 21.0.3
+ 21
+ 21
UTF-8
- 6.0.0
- 3.27.6
- 5.20.0
- 25
+
+ org.openjfx
+ javafx-controls
+ ${javafx.version}
+
+
+ org.openjfx
+ javafx-fxml
+ ${javafx.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.0
+
org.junit.jupiter
junit-jupiter
- ${junit.jupiter.version}
+ 5.10.2
test
- org.assertj
- assertj-core
- ${assertj.core.version}
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.10.2
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.10.2
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
test
org.mockito
mockito-junit-jupiter
- ${mockito.version}
+ 5.11.0
test
- org.openjfx
- javafx-controls
- ${javafx.version}
+ net.bytebuddy
+ byte-buddy
+ 1.15.10
+ test
- org.openjfx
- javafx-fxml
- ${javafx.version}
+ net.bytebuddy
+ byte-buddy-agent
+ 1.15.10
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.25.3
+ test
+
+
+ org.wiremock
+ wiremock
+ 3.9.2
+ test
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+
+
org.openjfx
javafx-maven-plugin
@@ -55,14 +100,22 @@
com.example.HelloFX
-
+
+
- javafx
- true
- true
- true
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ --add-opens java.base/java.lang=ALL-UNNAMED
+ --add-opens java.base/java.util=ALL-UNNAMED
+
-
+
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..acf4bcfe 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -3,9 +3,6 @@
import javafx.fxml.FXML;
import javafx.scene.control.Label;
-/**
- * Controller layer: mediates between the view (FXML) and the model.
- */
public class HelloController {
private final HelloModel model = new HelloModel();
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..480979ed 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,25 +1,27 @@
package com.example;
+import com.example.util.EnvLoader;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
-import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
+import java.io.IOException;
+
public class HelloFX extends Application {
@Override
- public void start(Stage stage) throws Exception {
- FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
- Parent root = fxmlLoader.load();
- Scene scene = new Scene(root, 640, 480);
- stage.setTitle("Hello MVC");
+ public void start(Stage stage) throws IOException {
+ FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("ChatView.fxml"));
+ Scene scene = new Scene(fxmlLoader.load());
+
+ stage.setTitle("Java25 Chat App");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
+ EnvLoader.load();
launch();
}
-
}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..d41955ff 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,12 +1,7 @@
package com.example;
-/**
- * Model layer: encapsulates application data and business logic.
- */
public class HelloModel {
- /**
- * Returns a greeting based on the current Java and JavaFX versions.
- */
+
public String getGreeting() {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
diff --git a/src/main/java/com/example/controller/ChatController.java b/src/main/java/com/example/controller/ChatController.java
new file mode 100644
index 00000000..cc6e1475
--- /dev/null
+++ b/src/main/java/com/example/controller/ChatController.java
@@ -0,0 +1,142 @@
+package com.example.controller;
+
+import com.example.model.ChatModel;
+import com.example.model.NtfyMessage;
+import javafx.fxml.FXML;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.concurrent.Task;
+import javafx.geometry.Pos;
+import javafx.stage.FileChooser;
+
+import java.io.File;
+
+public class ChatController {
+
+ @FXML
+ private ListView messageListView;
+
+ @FXML
+ private TextField messageInput;
+
+ @FXML
+ private Label statusLabel;
+
+ private ChatModel model;
+
+ @FXML
+ public void initialize() {
+ this.model = new ChatModel();
+
+ updateStatusOnline();
+
+ messageListView.setCellFactory(listView -> new ListCell() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ setGraphic(null);
+ setStyle("");
+ } else {
+ setText(item);
+ setAlignment(Pos.CENTER_LEFT);
+ setStyle("-fx-background-color: rgba(255, 250, 240, 0.6); " +
+ "-fx-text-fill: #3d3d3d; " +
+ "-fx-padding: 14 18 14 18; " +
+ "-fx-border-color: rgba(214, 69, 69, 0.15); " +
+ "-fx-border-width: 0 0 0 3; " +
+ "-fx-font-size: 13px; " +
+ "-fx-background-radius: 0; " +
+ "-fx-border-radius: 0; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.05), 3, 0, 0, 2);");
+ }
+ }
+ });
+
+ model.getMessages().addListener((javafx.collections.ListChangeListener.Change extends NtfyMessage> change) -> {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ for (NtfyMessage msg : change.getAddedSubList()) {
+ String formatted = "🌸 " + msg.message();
+ messageListView.getItems().add(formatted);
+ }
+ messageListView.scrollTo(messageListView.getItems().size() - 1);
+ }
+ }
+ });
+ }
+
+ @FXML
+ private void handleSendButtonAction() {
+ String text = messageInput.getText();
+ if (text != null && !text.trim().isEmpty()) {
+ Task task = new Task<>() {
+ @Override
+ protected Void call() throws Exception {
+ model.sendMessage(text.trim());
+ return null;
+ }
+ };
+
+ task.setOnSucceeded(e -> {
+ messageInput.clear();
+ updateStatusOnline();
+ });
+
+ task.setOnFailed(e -> {
+ System.err.println("Send failed: " + task.getException().getMessage());
+ updateStatusOffline();
+ });
+
+ new Thread(task).start();
+ }
+ }
+
+ @FXML
+ private void handleAttachFileAction() {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle("Select File");
+ File file = fileChooser.showOpenDialog(messageInput.getScene().getWindow());
+
+ if (file != null) {
+ Task task = new Task<>() {
+ @Override
+ protected Void call() throws Exception {
+ model.sendFile(file);
+ return null;
+ }
+ };
+
+ task.setOnSucceeded(e -> {
+ System.out.println("✅ File info sent: " + file.getName());
+ });
+
+ task.setOnFailed(e -> {
+ System.err.println("❌ File send failed: " + task.getException().getMessage());
+ });
+
+ new Thread(task).start();
+ }
+ }
+
+ private void updateStatusOnline() {
+ if (statusLabel != null) {
+ statusLabel.setText("● online");
+ statusLabel.setStyle("-fx-text-fill: #c93939; " +
+ "-fx-font-size: 10px; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
+ }
+ }
+
+ private void updateStatusOffline() {
+ if (statusLabel != null) {
+ statusLabel.setText("● offline");
+ statusLabel.setStyle("-fx-text-fill: #6b5d54; " +
+ "-fx-font-size: 10px; " +
+ "-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/model/ChatModel.java b/src/main/java/com/example/model/ChatModel.java
new file mode 100644
index 00000000..d70144d1
--- /dev/null
+++ b/src/main/java/com/example/model/ChatModel.java
@@ -0,0 +1,76 @@
+package com.example.model;
+
+import com.example.network.ChatNetworkClient;
+import com.example.network.NtfyHttpClient;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.io.File;
+
+public class ChatModel {
+
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final ChatNetworkClient networkClient;
+ private ChatNetworkClient.Subscription subscription;
+ private final String baseUrl;
+ private final String topic;
+
+ public ChatModel() {
+ this.networkClient = new NtfyHttpClient();
+
+ String url = System.getProperty("NTFY_BASE_URL");
+ if (url == null || url.isBlank()) {
+ url = System.getenv("NTFY_BASE_URL");
+ }
+ if (url == null || url.isBlank()) {
+ url = "http://localhost:8080";
+ }
+
+ this.baseUrl = url;
+ this.topic = "myChatTopic";
+
+ System.out.println("Using NTFY URL: " + this.baseUrl);
+ connect();
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public void connect() {
+ this.subscription = networkClient.subscribe(
+ baseUrl,
+ topic,
+ msg -> Platform.runLater(() -> messages.add(msg)),
+ error -> System.err.println("Error: " + error.getMessage())
+ );
+ }
+
+ public void sendMessage(String text) throws Exception {
+ if (text == null || text.trim().isEmpty()) {
+ throw new IllegalArgumentException("Message cannot be empty");
+ }
+
+ NtfyMessage message = new NtfyMessage(topic, text);
+ networkClient.send(baseUrl, message);
+ }
+
+ public void sendFile(File file) throws Exception {
+ if (file == null || !file.exists()) {
+ throw new IllegalArgumentException("File not found");
+ }
+
+ networkClient.sendFile(baseUrl, topic, file);
+
+ String fileMessage = "📎 " + file.getName() + " (" + formatFileSize(file.length()) + ")";
+ sendMessage(fileMessage);
+ }
+
+ private String formatFileSize(long size) {
+ if (size < 1024) return size + " B";
+ if (size < 1024 * 1024) return String.format("%.1f KB", size / 1024.0);
+ if (size < 1024 * 1024 * 1024) return String.format("%.1f MB", size / (1024.0 * 1024));
+ return String.format("%.1f GB", size / (1024.0 * 1024 * 1024));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/model/NtfyMessage.java b/src/main/java/com/example/model/NtfyMessage.java
new file mode 100644
index 00000000..a4f6fbb3
--- /dev/null
+++ b/src/main/java/com/example/model/NtfyMessage.java
@@ -0,0 +1,21 @@
+package com.example.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessage(
+ String id,
+ Long time,
+ String event,
+ String topic,
+ String message,
+ String attachment
+) {
+ public NtfyMessage(String topic, String message) {
+ this(null, null, "message", topic, message, null);
+ }
+
+ public NtfyMessage(String topic, String message, String attachment) {
+ this(null, null, "message", topic, message, attachment);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/network/ChatNetworkClient.java b/src/main/java/com/example/network/ChatNetworkClient.java
new file mode 100644
index 00000000..70df1195
--- /dev/null
+++ b/src/main/java/com/example/network/ChatNetworkClient.java
@@ -0,0 +1,18 @@
+package com.example.network;
+
+import com.example.model.NtfyMessage;
+import java.io.File;
+import java.io.IOException;
+import java.util.function.Consumer;
+
+public interface ChatNetworkClient {
+ void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException;
+
+ void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException;
+
+ Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError);
+
+ interface Subscription {
+ void close();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/network/NtfyHttpClient.java b/src/main/java/com/example/network/NtfyHttpClient.java
new file mode 100644
index 00000000..9f64d20e
--- /dev/null
+++ b/src/main/java/com/example/network/NtfyHttpClient.java
@@ -0,0 +1,108 @@
+package com.example.network;
+
+import com.example.model.NtfyMessage;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+public class NtfyHttpClient implements ChatNetworkClient {
+
+ private final HttpClient http;
+ private final ObjectMapper objectMapper;
+
+ public NtfyHttpClient() {
+ this.http = HttpClient.newHttpClient();
+ this.objectMapper = new ObjectMapper();
+ }
+
+ @Override
+ public void send(String baseUrl, NtfyMessage message) throws IOException, InterruptedException {
+ String json = objectMapper.writeValueAsString(message);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(ensureNoTrailingSlash(baseUrl)))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
+ .build();
+
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Failed to send message. Status: " + response.statusCode());
+ }
+ }
+
+ @Override
+ public void sendFile(String baseUrl, String topic, File file) throws IOException, InterruptedException {
+ if (!file.exists()) {
+ throw new IOException("File not found: " + file.getName());
+ }
+
+ byte[] fileBytes = Files.readAllBytes(file.toPath());
+ String url = ensureNoTrailingSlash(baseUrl) + "/" + topic;
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Filename", file.getName())
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(fileBytes))
+ .build();
+
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ String errorBody = response.body();
+ throw new IOException("Failed to send file. Status: " + response.statusCode() + ", Error: " + errorBody);
+ }
+
+ System.out.println("✅ File uploaded successfully: " + file.getName());
+ }
+
+ @Override
+ public Subscription subscribe(String baseUrl, String topic, Consumer onMessage, Consumer onError) {
+ String url = ensureNoTrailingSlash(baseUrl) + "/" + topic + "/json";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ CompletableFuture future = http.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
+ .thenAccept(response -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (!line.trim().isEmpty()) {
+ NtfyMessage message = objectMapper.readValue(line.trim(), NtfyMessage.class);
+ if ("message".equals(message.event())) {
+ onMessage.accept(message);
+ }
+ }
+ }
+ } catch (Exception e) {
+ onError.accept(e);
+ }
+ })
+ .exceptionally(error -> {
+ onError.accept(error);
+ return null;
+ });
+
+ return () -> future.cancel(true);
+ }
+
+ private String ensureNoTrailingSlash(String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/util/EnvLoader.java b/src/main/java/com/example/util/EnvLoader.java
new file mode 100644
index 00000000..ca40fb9a
--- /dev/null
+++ b/src/main/java/com/example/util/EnvLoader.java
@@ -0,0 +1,41 @@
+package com.example.util;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public class EnvLoader {
+
+ public static void load() {
+ Path envPath = Paths.get(".env");
+
+ if (!Files.exists(envPath)) {
+ System.out.println("No .env file found, using system environment variables");
+ return;
+ }
+
+ try (BufferedReader reader = new BufferedReader(new FileReader(envPath.toFile()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+
+ String[] parts = line.split("=", 2);
+ if (parts.length == 2) {
+ String key = parts[0].trim();
+ String value = parts[1].trim();
+ System.setProperty(key, value);
+ System.out.println("Loaded env: " + key + "=" + value);
+ }
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to load .env file: " + e.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..3956337c 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,7 +1,16 @@
-module hellofx {
+module com.example.javafxchatapp {
+ exports com.example;
+ exports com.example.controller;
+ exports com.example.model;
+ exports com.example.network;
+ exports com.example.util;
+
requires javafx.controls;
requires javafx.fxml;
+ requires java.net.http;
+ requires com.fasterxml.jackson.databind;
- opens com.example to javafx.fxml;
- exports com.example;
+ opens com.example.controller to javafx.fxml;
+ opens com.example to javafx.graphics;
+ opens com.example.model to com.fasterxml.jackson.databind;
}
\ No newline at end of file
diff --git a/src/main/resources/com/example/ChatView.fxml b/src/main/resources/com/example/ChatView.fxml
new file mode 100644
index 00000000..f9d9185f
--- /dev/null
+++ b/src/main/resources/com/example/ChatView.fxml
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/test/java/com/example/model/ChatModelTest.java b/src/test/java/com/example/model/ChatModelTest.java
new file mode 100644
index 00000000..dafb58eb
--- /dev/null
+++ b/src/test/java/com/example/model/ChatModelTest.java
@@ -0,0 +1,57 @@
+package com.example.model;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ChatModelTest {
+
+ private ChatModel model;
+
+ @BeforeEach
+ void setup() {
+ model = new ChatModel();
+ }
+
+ @Test
+ @DisplayName("Should have empty message list initially")
+ void testInitialState() {
+ assertNotNull(model.getMessages());
+ assertTrue(model.getMessages().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should return observable list of messages")
+ void testGetMessages() {
+ assertNotNull(model.getMessages());
+ assertEquals(0, model.getMessages().size());
+ }
+
+ @Test
+ @DisplayName("Should allow adding messages to list")
+ void testAddMessageToList() {
+ NtfyMessage message = new NtfyMessage("testTopic", "Test message");
+ model.getMessages().add(message);
+
+ assertEquals(1, model.getMessages().size());
+ assertEquals("Test message", model.getMessages().get(0).message());
+ }
+
+ @Test
+ @DisplayName("Should maintain message order")
+ void testMessageOrder() {
+ NtfyMessage msg1 = new NtfyMessage("topic", "First");
+ NtfyMessage msg2 = new NtfyMessage("topic", "Second");
+ NtfyMessage msg3 = new NtfyMessage("topic", "Third");
+
+ model.getMessages().add(msg1);
+ model.getMessages().add(msg2);
+ model.getMessages().add(msg3);
+
+ assertEquals("First", model.getMessages().get(0).message());
+ assertEquals("Second", model.getMessages().get(1).message());
+ assertEquals("Third", model.getMessages().get(2).message());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/model/NtfyMesageTest.java b/src/test/java/com/example/model/NtfyMesageTest.java
new file mode 100644
index 00000000..746e313a
--- /dev/null
+++ b/src/test/java/com/example/model/NtfyMesageTest.java
@@ -0,0 +1,83 @@
+package com.example.model;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NtfyMessageTest {
+
+ @Test
+ @DisplayName("Should create message with topic and message text")
+ void testCreateMessage() {
+ NtfyMessage message = new NtfyMessage("testTopic", "Hello World");
+
+ assertEquals("testTopic", message.topic());
+ assertEquals("Hello World", message.message());
+ assertNotNull(message);
+ }
+
+ @Test
+ @DisplayName("Should set default event type to 'message'")
+ void testDefaultEventType() {
+ NtfyMessage message = new NtfyMessage("testTopic", "Test");
+
+ assertEquals("message", message.event());
+ }
+
+ @Test
+ @DisplayName("Should handle null id and time for new messages")
+ void testNewMessageHasNullIdAndTime() {
+ NtfyMessage message = new NtfyMessage("testTopic", "Test");
+
+ assertNull(message.id());
+ assertNull(message.time());
+ }
+
+ @Test
+ @DisplayName("Should create message with all fields using canonical constructor")
+ void testFullConstructor() {
+ NtfyMessage message = new NtfyMessage(
+ "abc123", // id
+ 1234567890L, // time
+ "message", // event
+ "testTopic", // topic
+ "Hello", // message
+ null // attachment
+ );
+
+ assertEquals("abc123", message.id());
+ assertEquals(1234567890L, message.time());
+ assertEquals("message", message.event());
+ assertEquals("testTopic", message.topic());
+ assertEquals("Hello", message.message());
+ }
+
+ @Test
+ @DisplayName("Should accept empty message text")
+ void testEmptyMessage() {
+ NtfyMessage message = new NtfyMessage("testTopic", "");
+
+ assertEquals("", message.message());
+ assertEquals("testTopic", message.topic());
+ }
+
+ @Test
+ @DisplayName("Should create message with attachment")
+ void testMessageWithAttachment() {
+ NtfyMessage message = new NtfyMessage("testTopic", "File sent", "test.pdf");
+
+ assertEquals("testTopic", message.topic());
+ assertEquals("File sent", message.message());
+ assertEquals("test.pdf", message.attachment());
+ }
+
+ @Test
+ @DisplayName("Should handle special characters in message")
+ void testSpecialCharacters() {
+ String specialMessage = "Hello! 🌸 @user #topic";
+ NtfyMessage message = new NtfyMessage("testTopic", specialMessage);
+
+ assertEquals(specialMessage, message.message());
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/.gitkeep b/src/test/resources/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/test.txt b/test.txt
new file mode 100644
index 00000000..418a98ce
Binary files /dev/null and b/test.txt differ