diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
new file mode 100644
index 0000000..fe9c87b
--- /dev/null
+++ b/.github/badges/branches.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
new file mode 100644
index 0000000..bf86c61
--- /dev/null
+++ b/.github/badges/jacoco.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 0000000..20c420b
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,45 @@
+name: Java CI with Maven
+
+on:
+ push:
+ branches: [ "master", "main", "dockerization" ]
+ pull_request:
+ branches: [ "master", "main", "dockerization" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Use head_ref for PRs, and ref_name for pushes to stay on a branch
+ ref: ${{ github.head_ref || github.ref_name }}
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: maven
+ - name: Build with Maven
+ run: mvn -B package -Dspring.profiles.active=test --file pom.xml
+
+ - name: Generate JaCoCo Badge
+ uses: cicirello/jacoco-badge-generator@v2
+ with:
+ generate-branches-badge: true
+ jacoco-csv-file: target/site/jacoco/jacoco.csv
+
+ - name: Log coverage percentage
+ run: |
+ echo "coverage = ${{ steps.jacoco.outputs.coverage }}"
+ echo "branch coverage = ${{ steps.jacoco.outputs.branches }}"
+
+ - name: Commit and push the badge (if it changed)
+ uses: EndBug/add-and-commit@v9
+ with:
+ default_author: github_actions
+ message: 'Update coverage badge'
+ add: '*.svg'
diff --git a/README.md b/README.md
index 7b670d8..cb76029 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
# ๐ Modern Task Manager - Portfolio Project
+
+
+
A comprehensive, full-stack Task Management application built with **Spring Boot 3** and **Java 21**. This project serves as a showcase of modern software engineering practices, featuring a layered architecture, robust security, and a responsive, interactive UI.
---
@@ -27,6 +30,23 @@ This project is designed to be easily testable by anyone, whether they are a dev
### **4. Containerized Portability (Docker)**
* No Java installed? No problem. Run `docker-compose up --build` to spin up both the application and a **PostgreSQL** database in isolated containers.
+### **5. CI/CD with GitHub Actions**
+* The project includes a GitHub Actions workflow (`maven.yml`) that automatically builds the project and runs all tests on every push or pull request to the `master`, `main`, or `dockerization` branches.
+
+---
+
+## ๐งช Testing
+
+The project includes a suite of unit tests for the service layer, ensuring business logic correctness.
+
+* **TaskServiceTest**: Tests CRUD operations, user-based task access, and validation logic.
+* **UserServiceTest**: Tests user registration, password encryption, and Spring Security's `UserDetailsService` implementation.
+
+To run the tests locally, use:
+```bash
+./mvnw test
+```
+
---
## ๐ ๏ธ Tech Stack & Skills Showcased
diff --git a/pom.xml b/pom.xml
index e0ce5e2..cc3450f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -104,6 +104,25 @@
21
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+
diff --git a/src/test/java/com/lucc/taskmanager/TaskManagerApplicationTests.java b/src/test/java/com/lucc/taskmanager/TaskManagerApplicationTests.java
index 917d715..ab21d28 100644
--- a/src/test/java/com/lucc/taskmanager/TaskManagerApplicationTests.java
+++ b/src/test/java/com/lucc/taskmanager/TaskManagerApplicationTests.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
+@ActiveProfiles("test")
class TaskManagerApplicationTests {
@Test
diff --git a/src/test/java/com/lucc/taskmanager/service/TaskServiceTest.java b/src/test/java/com/lucc/taskmanager/service/TaskServiceTest.java
new file mode 100644
index 0000000..9e70bc8
--- /dev/null
+++ b/src/test/java/com/lucc/taskmanager/service/TaskServiceTest.java
@@ -0,0 +1,123 @@
+package com.lucc.taskmanager.service;
+
+import com.lucc.taskmanager.model.Priority;
+import com.lucc.taskmanager.model.Status;
+import com.lucc.taskmanager.model.Task;
+import com.lucc.taskmanager.model.User;
+import com.lucc.taskmanager.repository.TaskRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class TaskServiceTest {
+
+ @Mock
+ private TaskRepository taskRepository;
+
+ @InjectMocks
+ private TaskService taskService;
+
+ private User user;
+ private Task task;
+
+ @BeforeEach
+ void setUp() {
+ user = new User();
+ user.setUsername("testuser");
+
+ task = new Task();
+ task.setId(1);
+ task.setTitle("Test Task");
+ task.setDescription("Test Description");
+ task.setUser(user);
+ task.setStatus(Status.TODO);
+ task.setPriority(Priority.MEDIUM);
+ }
+
+ @Test
+ void getTasksByUser_ShouldReturnTasks() {
+ when(taskRepository.findByUser(user)).thenReturn(Arrays.asList(task));
+
+ List tasks = taskService.getTasksByUser(user);
+
+ assertEquals(1, tasks.size());
+ assertEquals(task.getTitle(), tasks.get(0).getTitle());
+ verify(taskRepository, times(1)).findByUser(user);
+ }
+
+ @Test
+ void addTask_ShouldSaveTask() {
+ when(taskRepository.save(any(Task.class))).thenReturn(task);
+
+ Task savedTask = taskService.addTask(new Task(), user);
+
+ assertNotNull(savedTask);
+ assertEquals(user, savedTask.getUser());
+ verify(taskRepository, times(1)).save(any(Task.class));
+ }
+
+ @Test
+ void getTaskById_ShouldReturnTask_WhenTaskExistsAndUserMatches() {
+ when(taskRepository.findById(1)).thenReturn(Optional.of(task));
+
+ Task foundTask = taskService.getTaskById(1, user);
+
+ assertNotNull(foundTask);
+ assertEquals(1, foundTask.getId());
+ }
+
+ @Test
+ void getTaskById_ShouldThrowException_WhenTaskDoesNotExist() {
+ when(taskRepository.findById(1)).thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class, () -> taskService.getTaskById(1, user));
+ }
+
+ @Test
+ void getTaskById_ShouldThrowException_WhenUserDoesNotMatch() {
+ User otherUser = new User();
+ otherUser.setUsername("otheruser");
+ when(taskRepository.findById(1)).thenReturn(Optional.of(task));
+
+ assertThrows(IllegalArgumentException.class, () -> taskService.getTaskById(1, otherUser));
+ }
+
+ @Test
+ void updateTask_ShouldUpdateAndSaveTask() {
+ Task updatedInfo = new Task();
+ updatedInfo.setTitle("Updated Title");
+ updatedInfo.setDescription("Updated Description");
+ updatedInfo.setStatus(Status.DONE);
+ updatedInfo.setPriority(Priority.HIGH);
+
+ when(taskRepository.findById(1)).thenReturn(Optional.of(task));
+ when(taskRepository.save(any(Task.class))).thenReturn(task);
+
+ Task result = taskService.updateTask(1, updatedInfo, user);
+
+ assertEquals("Updated Title", result.getTitle());
+ assertEquals(Status.DONE, result.getStatus());
+ verify(taskRepository, times(1)).save(task);
+ }
+
+ @Test
+ void deleteTask_ShouldDeleteTask_WhenTaskExistsAndUserMatches() {
+ when(taskRepository.findById(1)).thenReturn(Optional.of(task));
+
+ taskService.deleteTask(1, user);
+
+ verify(taskRepository, times(1)).delete(task);
+ }
+}
diff --git a/src/test/java/com/lucc/taskmanager/service/UserServiceTest.java b/src/test/java/com/lucc/taskmanager/service/UserServiceTest.java
new file mode 100644
index 0000000..30e77c4
--- /dev/null
+++ b/src/test/java/com/lucc/taskmanager/service/UserServiceTest.java
@@ -0,0 +1,85 @@
+package com.lucc.taskmanager.service;
+
+import com.lucc.taskmanager.model.Role;
+import com.lucc.taskmanager.model.User;
+import com.lucc.taskmanager.repository.UserRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class UserServiceTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ @InjectMocks
+ private UserService userService;
+
+ private User user;
+
+ @BeforeEach
+ void setUp() {
+ user = new User();
+ user.setUsername("testuser");
+ user.setPassword("password");
+ user.setEmail("test@example.com");
+ }
+
+ @Test
+ void getUsers_ShouldReturnAllUsers() {
+ when(userRepository.findAll()).thenReturn(Arrays.asList(user));
+
+ List users = userService.getUsers();
+
+ assertEquals(1, users.size());
+ verify(userRepository, times(1)).findAll();
+ }
+
+ @Test
+ void addUser_ShouldEncryptPasswordAndSaveUser() {
+ when(passwordEncoder.encode("password")).thenReturn("encryptedPassword");
+ when(userRepository.save(any(User.class))).thenReturn(user);
+
+ User savedUser = userService.addUser(user);
+
+ assertNotNull(savedUser);
+ assertEquals(Role.USER, user.getRole()); // Default role
+ assertEquals("encryptedPassword", user.getPassword());
+ verify(userRepository, times(1)).save(user);
+ }
+
+ @Test
+ void loadUserByUsername_ShouldReturnUser_WhenUserExists() {
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
+
+ UserDetails userDetails = userService.loadUserByUsername("testuser");
+
+ assertNotNull(userDetails);
+ assertEquals("testuser", userDetails.getUsername());
+ }
+
+ @Test
+ void loadUserByUsername_ShouldThrowException_WhenUserDoesNotExist() {
+ when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty());
+
+ assertThrows(UsernameNotFoundException.class, () -> userService.loadUserByUsername("unknown"));
+ }
+}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
new file mode 100644
index 0000000..9fe88ae
--- /dev/null
+++ b/src/test/resources/application-test.properties
@@ -0,0 +1,9 @@
+# Use H2 for tests
+spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=password
+
+# Hibernate settings for H2
+spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
+spring.jpa.hibernate.ddl-auto=create-drop