feat(task): adopt JPA feature boundaries
This commit is contained in:
+52
-4
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
package com.lab.labtimesheet.feature.task.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -15,6 +15,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -47,12 +57,25 @@ class TaskControllerTest {
|
||||
@Test
|
||||
void emptyTaskListRendersNotApplicableProgress() throws Exception {
|
||||
given(taskService.list(ACTOR_EMAIL, 10L))
|
||||
.willReturn(new TaskListView(List.of(), TaskProgress.from(List.of())));
|
||||
.willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()), false));
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("tasks/list"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("N/A")));
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("N/A")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("Create Task"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskListShowsAssigneeAndCreateActionOnlyWhenAllowed() throws Exception {
|
||||
given(taskService.list(ACTOR_EMAIL, 10L)).willReturn(new TaskListView(
|
||||
List.of(task(25L)), TaskProgress.from(List.of(TaskStatus.TODO)), true));
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Create Task")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,10 +146,35 @@ class TaskControllerTest {
|
||||
.andExpect(redirectedUrl("/projects/10/tasks/25"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailsHideUnavailableActionsAndShowAssignee() throws Exception {
|
||||
given(taskService.details(ACTOR_EMAIL, 10L, 25L))
|
||||
.willReturn(new TaskDetails(task(25L), List.of(), false, false));
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Member Name")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("Change status"))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("Add comment"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailsRenderAvailableActions() throws Exception {
|
||||
given(taskService.details(ACTOR_EMAIL, 10L, 25L))
|
||||
.willReturn(new TaskDetails(task(25L), List.of(), true, true));
|
||||
|
||||
mockMvc.perform(get("/projects/10/tasks/25").with(user(ACTOR_EMAIL)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Change status")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Add comment")));
|
||||
}
|
||||
|
||||
private static TaskView task(long id) {
|
||||
Instant instant = Instant.parse("2026-08-14T10:00:00Z");
|
||||
return new TaskView(
|
||||
id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO,
|
||||
id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO,
|
||||
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
package com.lab.labtimesheet.feature.task.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.lab.labtimesheet.feature.task.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.entity.Task;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
class TaskPersistenceStructureTest {
|
||||
|
||||
@Test
|
||||
void taskPersistenceUsesJpaEntitiesAndSpringDataRepositories() {
|
||||
assertThat(Task.class).hasAnnotation(Entity.class);
|
||||
assertThat(TaskComment.class).hasAnnotation(Entity.class);
|
||||
assertThat(JpaRepository.class).isAssignableFrom(TaskRepository.class);
|
||||
assertThat(JpaRepository.class).isAssignableFrom(TaskCommentRepository.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskServiceUsesTaskRepositoriesInsteadOfDirectJdbcAccess() {
|
||||
var constructorTypes = Arrays.stream(TaskService.class.getDeclaredConstructors())
|
||||
.flatMap(constructor -> Arrays.stream(constructor.getParameterTypes()))
|
||||
.toList();
|
||||
|
||||
assertThat(constructorTypes)
|
||||
.contains(TaskRepository.class, TaskCommentRepository.class)
|
||||
.doesNotContain(JdbcClient.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskMutationLookupUsesAPessimisticWriteLock() throws NoSuchMethodException {
|
||||
var method = TaskRepository.class.getMethod(
|
||||
"findLockedByIdAndProjectIdAndDeletedAtIsNull", long.class, long.class);
|
||||
|
||||
Lock lock = method.getAnnotation(Lock.class);
|
||||
assertThat(lock).isNotNull();
|
||||
assertThat(lock.value()).isEqualTo(LockModeType.PESSIMISTIC_WRITE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskBusinessCodeContainsNoDirectJdbcOrSqlImports() throws IOException {
|
||||
Path taskSource = Path.of("src/main/java/com/lab/labtimesheet/feature/task");
|
||||
try (var sources = Files.walk(taskSource)) {
|
||||
var directSqlSources = sources
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> {
|
||||
try {
|
||||
String source = Files.readString(path);
|
||||
return source.contains("import org.springframework.jdbc")
|
||||
|| source.contains("import java.sql");
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Cannot inspect " + path, exception);
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
|
||||
assertThat(directSqlSources).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
-11
@@ -1,19 +1,21 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.lab.labtimesheet.tasks.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.tasks.TaskCommentView;
|
||||
import com.lab.labtimesheet.tasks.TaskDetails;
|
||||
import com.lab.labtimesheet.tasks.TaskListView;
|
||||
import com.lab.labtimesheet.tasks.TaskAssigneeChoice;
|
||||
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.tasks.TaskService;
|
||||
import com.lab.labtimesheet.tasks.TaskStatus;
|
||||
import com.lab.labtimesheet.tasks.TaskValidationException;
|
||||
import com.lab.labtimesheet.tasks.TaskView;
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -35,9 +37,18 @@ class TaskCreationIntegrationTest {
|
||||
@Autowired
|
||||
private JdbcClient jdbc;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Autowired
|
||||
private TaskService taskService;
|
||||
|
||||
@Autowired
|
||||
private TaskQueryService taskQueries;
|
||||
|
||||
@Autowired
|
||||
private TaskDashboardService taskDashboard;
|
||||
|
||||
private long projectId;
|
||||
private long leaderMembershipId;
|
||||
private long memberMembershipId;
|
||||
@@ -73,6 +84,7 @@ class TaskCreationIntegrationTest {
|
||||
assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId);
|
||||
assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId);
|
||||
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
|
||||
assertThat(task.assigneeName()).isEqualTo("member@example.test");
|
||||
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"member@example.test",
|
||||
@@ -208,6 +220,9 @@ class TaskCreationIntegrationTest {
|
||||
|
||||
assertThat(list.tasks()).extracting(TaskView::title)
|
||||
.containsExactly("Todo", "Active", "Blocked", "Done");
|
||||
assertThat(list.tasks()).extracting(TaskView::assigneeName)
|
||||
.containsOnly("member@example.test");
|
||||
assertThat(list.canCreate()).isTrue();
|
||||
assertThat(list.progress().total()).isEqualTo(4);
|
||||
assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1);
|
||||
assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).isEqualTo(1);
|
||||
@@ -215,8 +230,24 @@ class TaskCreationIntegrationTest {
|
||||
assertThat(list.progress().count(TaskStatus.DONE)).isEqualTo(1);
|
||||
assertThat(list.progress().completionPercentage()).hasValue(25.0);
|
||||
assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment");
|
||||
assertThat(details.task().assigneeName()).isEqualTo("member@example.test");
|
||||
assertThat(details.canChangeStatus()).isFalse();
|
||||
assertThat(details.canComment()).isTrue();
|
||||
|
||||
long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
|
||||
long emptyLeaderMembershipId = insertMembership(
|
||||
emptyProjectId,
|
||||
userId("leader@example.test"),
|
||||
userId("mentor@example.test"));
|
||||
jdbc.sql("""
|
||||
insert into project_leadership_terms
|
||||
(project_id, membership_id, appointed_by_mentor_user_id)
|
||||
values (:projectId, :membershipId, :mentorId)
|
||||
""")
|
||||
.param("projectId", emptyProjectId)
|
||||
.param("membershipId", emptyLeaderMembershipId)
|
||||
.param("mentorId", userId("mentor@example.test"))
|
||||
.update();
|
||||
assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage())
|
||||
.isEmpty();
|
||||
}
|
||||
@@ -234,6 +265,60 @@ class TaskCreationIntegrationTest {
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void formerMemberReadsOnlyCompletedProjectTaskHistory() {
|
||||
TaskView task = createMemberTask("Historical task");
|
||||
closeMembership(memberMembershipId);
|
||||
|
||||
assertThatThrownBy(() -> taskService.list("member@example.test", projectId))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThatThrownBy(() -> taskService.details("member@example.test", projectId, task.id()))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
|
||||
completeProject();
|
||||
|
||||
assertThat(currentLeadershipCount()).isZero();
|
||||
assertThat(currentMembershipCount()).isZero();
|
||||
|
||||
assertThat(taskService.list("member@example.test", projectId).tasks())
|
||||
.extracting(TaskView::title)
|
||||
.containsExactly("Historical task");
|
||||
assertThat(taskService.details("member@example.test", projectId, task.id()).task().title())
|
||||
.isEqualTo("Historical task");
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewCapabilitiesFollowCurrentMembershipAssignmentAndProjectLifecycle() {
|
||||
TaskView task = createMemberTask("Capability task");
|
||||
|
||||
assertThat(taskService.list("member@example.test", projectId).canCreate()).isTrue();
|
||||
assertThat(taskService.list("mentor@example.test", projectId).canCreate()).isFalse();
|
||||
assertThat(taskService.details("member@example.test", projectId, task.id()))
|
||||
.satisfies(details -> {
|
||||
assertThat(details.canChangeStatus()).isFalse();
|
||||
assertThat(details.canComment()).isTrue();
|
||||
});
|
||||
|
||||
activateProject();
|
||||
|
||||
assertThat(taskService.details("member@example.test", projectId, task.id()))
|
||||
.satisfies(details -> {
|
||||
assertThat(details.canChangeStatus()).isTrue();
|
||||
assertThat(details.canComment()).isTrue();
|
||||
});
|
||||
assertThat(taskService.details("leader@example.test", projectId, task.id()).canChangeStatus())
|
||||
.isFalse();
|
||||
|
||||
completeProject();
|
||||
|
||||
assertThat(taskService.list("member@example.test", projectId).canCreate()).isFalse();
|
||||
assertThat(taskService.details("member@example.test", projectId, task.id()))
|
||||
.satisfies(details -> {
|
||||
assertThat(details.canChangeStatus()).isFalse();
|
||||
assertThat(details.canComment()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() {
|
||||
assertThat(taskService.assignmentChoices("member@example.test", projectId))
|
||||
@@ -244,6 +329,45 @@ class TaskCreationIntegrationTest {
|
||||
.containsExactly(leaderMembershipId, memberMembershipId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectActivationQueryCountsOnlyCurrentTasksOutsideActiveMemberships() {
|
||||
createMemberTask("Member task");
|
||||
TaskView leaderTask = taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, leaderMembershipId, "Leader task", null, null));
|
||||
|
||||
assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId)))
|
||||
.isEqualTo(1L);
|
||||
|
||||
softDelete(leaderTask.id());
|
||||
assertThat(taskQueries.countCurrentTasksAssignedOutside(projectId, Set.of(memberMembershipId)))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void internDashboardCountsAssignmentsAndOrdersFivePriorityTasks() {
|
||||
createMemberTask("Late");
|
||||
taskService.create("member@example.test", new CreateTaskCommand(
|
||||
projectId, memberMembershipId, "No due date", null, null));
|
||||
taskService.create("member@example.test", new CreateTaskCommand(
|
||||
projectId, memberMembershipId, "Earliest A", null, LocalDate.of(2026, 8, 10)));
|
||||
taskService.create("member@example.test", new CreateTaskCommand(
|
||||
projectId, memberMembershipId, "Earliest B", null, LocalDate.of(2026, 8, 10)));
|
||||
taskService.create("member@example.test", new CreateTaskCommand(
|
||||
projectId, memberMembershipId, "Middle", null, LocalDate.of(2026, 8, 11)));
|
||||
taskService.create("member@example.test", new CreateTaskCommand(
|
||||
projectId, memberMembershipId, "Next", null, LocalDate.of(2026, 8, 13)));
|
||||
setDueDateForTitle("Late", LocalDate.of(2026, 8, 12));
|
||||
activateProject();
|
||||
|
||||
var dashboard = taskDashboard.dashboard("member@example.test");
|
||||
|
||||
assertThat(dashboard.assignedTaskCount()).isEqualTo(6L);
|
||||
assertThat(dashboard.priorityTasks())
|
||||
.extracting(task -> task.title())
|
||||
.containsExactly("Earliest A", "Earliest B", "Middle", "Late", "Next");
|
||||
}
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
return jdbc.sql("""
|
||||
insert into app_users
|
||||
@@ -328,6 +452,26 @@ class TaskCreationIntegrationTest {
|
||||
return jdbc.sql("select count(*) from task_comments").query(Long.class).single();
|
||||
}
|
||||
|
||||
private long currentLeadershipCount() {
|
||||
return jdbc.sql("""
|
||||
select count(*) from project_leadership_terms
|
||||
where project_id = :projectId and ended_at is null
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private long currentMembershipCount() {
|
||||
return jdbc.sql("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = :projectId and left_at is null
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private TaskView createMemberTask(String title) {
|
||||
return taskService.create(
|
||||
"member@example.test",
|
||||
@@ -338,9 +482,34 @@ class TaskCreationIntegrationTest {
|
||||
jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void completeProject() {
|
||||
long mentorId = userId("mentor@example.test");
|
||||
jdbc.sql("""
|
||||
update tasks
|
||||
set status = 'DONE'
|
||||
where project_id = :id and deleted_at is null
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
update project_leadership_terms
|
||||
set ended_at = started_at + interval '1 second', ended_by_mentor_user_id = :mentorId
|
||||
where project_id = :id and ended_at is null
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.param("mentorId", mentorId)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
update project_memberships
|
||||
set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId
|
||||
where project_id = :id and left_at is null
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.param("mentorId", mentorId)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
update projects
|
||||
set status = 'COMPLETED', activated_at = current_timestamp,
|
||||
@@ -349,6 +518,7 @@ class TaskCreationIntegrationTest {
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void setStatus(long taskId, TaskStatus status) {
|
||||
@@ -356,6 +526,7 @@ class TaskCreationIntegrationTest {
|
||||
.param("status", status.name())
|
||||
.param("id", taskId)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void softDelete(long taskId) {
|
||||
@@ -367,5 +538,26 @@ class TaskCreationIntegrationTest {
|
||||
.param("membershipId", memberMembershipId)
|
||||
.param("id", taskId)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void closeMembership(long membershipId) {
|
||||
jdbc.sql("""
|
||||
update project_memberships
|
||||
set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId
|
||||
where id = :id
|
||||
""")
|
||||
.param("mentorId", userId("mentor@example.test"))
|
||||
.param("id", membershipId)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void setDueDateForTitle(String title, LocalDate dueDate) {
|
||||
jdbc.sql("update tasks set due_date = :dueDate where title = :title")
|
||||
.param("dueDate", dueDate)
|
||||
.param("title", title)
|
||||
.update();
|
||||
entityManager.clear();
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.Task;
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
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.data.domain.Pageable;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskDashboardServiceTest {
|
||||
|
||||
@Mock
|
||||
private TaskRepository tasks;
|
||||
|
||||
@Mock
|
||||
private ProjectQueryService projects;
|
||||
|
||||
@InjectMocks
|
||||
private TaskDashboardService dashboardService;
|
||||
|
||||
@Test
|
||||
void mentorDashboardCountsBlockedTasksOnlyInOwnedActiveProjects() {
|
||||
given(projects.authenticatedActor("mentor@example.test"))
|
||||
.willReturn(new ProjectActorView(3L, "MENTOR"));
|
||||
given(projects.listVisible(3L)).willReturn(List.of(
|
||||
summary(10L, "Active", "ACTIVE"),
|
||||
summary(11L, "Planned", "PLANNED")));
|
||||
given(tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(List.of(10L), TaskStatus.BLOCKED))
|
||||
.willReturn(4L);
|
||||
|
||||
var dashboard = dashboardService.dashboard("mentor@example.test");
|
||||
|
||||
assertThat(dashboard.blockedTaskCount()).isEqualTo(4L);
|
||||
assertThat(dashboard.assignedTaskCount()).isZero();
|
||||
assertThat(dashboard.priorityTasks()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void internDashboardExcludesFormerMembershipsAndReturnsFiveDueDatePriorities() {
|
||||
given(projects.authenticatedActor("intern@example.test"))
|
||||
.willReturn(new ProjectActorView(5L, "INTERN"));
|
||||
given(projects.listVisible(5L)).willReturn(List.of(
|
||||
summary(10L, "Current", "ACTIVE"),
|
||||
summary(11L, "Former", "ACTIVE"),
|
||||
summary(12L, "Completed", "COMPLETED")));
|
||||
given(projects.taskContext(5L, 10L)).willReturn(context(
|
||||
10L, List.of(new ProjectTaskMemberView(70L, 5L, "Intern"))));
|
||||
given(projects.taskContext(5L, 11L)).willReturn(context(11L, List.of()));
|
||||
given(tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
|
||||
List.of(10L), List.of(70L)))
|
||||
.willReturn(6L);
|
||||
var priority = new Task(
|
||||
10L,
|
||||
70L,
|
||||
"Due first",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 16),
|
||||
70L,
|
||||
Instant.parse("2026-08-15T00:00:00Z"));
|
||||
given(tasks.findPriorityTasks(
|
||||
org.mockito.ArgumentMatchers.eq(List.of(10L)),
|
||||
org.mockito.ArgumentMatchers.eq(List.of(70L)),
|
||||
org.mockito.ArgumentMatchers.any(Pageable.class)))
|
||||
.willReturn(List.of(priority));
|
||||
|
||||
var dashboard = dashboardService.dashboard("intern@example.test");
|
||||
|
||||
assertThat(dashboard.blockedTaskCount()).isZero();
|
||||
assertThat(dashboard.assignedTaskCount()).isEqualTo(6L);
|
||||
assertThat(dashboard.priorityTasks()).singleElement().satisfies(task -> {
|
||||
assertThat(task.title()).isEqualTo("Due first");
|
||||
assertThat(task.projectName()).isEqualTo("Current");
|
||||
assertThat(task.status()).isEqualTo(TaskStatus.TODO);
|
||||
assertThat(task.dueDate()).isEqualTo(LocalDate.of(2026, 8, 16));
|
||||
});
|
||||
}
|
||||
|
||||
private static ProjectSummary summary(long id, String name, String status) {
|
||||
return new ProjectSummary(
|
||||
id, name, status, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
||||
}
|
||||
|
||||
private static ProjectTaskContext context(long id, List<ProjectTaskMemberView> members) {
|
||||
return new ProjectTaskContext(
|
||||
id,
|
||||
3L,
|
||||
"ACTIVE",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
null,
|
||||
members);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.Task;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskCommentRepository;
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskMutationBoundaryTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-15T00:00:00Z");
|
||||
|
||||
@Mock private TaskRepository tasks;
|
||||
@Mock private TaskCommentRepository comments;
|
||||
@Mock private ProjectQueryService projectQueries;
|
||||
@Mock private ProjectService projectMutations;
|
||||
@Mock private CalendarApplicationService calendar;
|
||||
|
||||
private TaskService service;
|
||||
private ProjectTaskContext context;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new TaskService(
|
||||
tasks,
|
||||
comments,
|
||||
projectQueries,
|
||||
projectMutations,
|
||||
calendar,
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
context = new ProjectTaskContext(
|
||||
10L,
|
||||
3L,
|
||||
"ACTIVE",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
70L,
|
||||
List.of(new ProjectTaskMemberView(70L, 5L, "Member")));
|
||||
when(projectQueries.authenticatedActor("member@example.test"))
|
||||
.thenReturn(new ProjectActorView(5L, "INTERN"));
|
||||
when(projectMutations.taskMutationContext(5L, 10L)).thenReturn(context);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createLocksAndRechecksProjectBeforeWriting() {
|
||||
Task saved = taskForView(TaskStatus.TODO);
|
||||
when(tasks.saveAndFlush(any(Task.class))).thenReturn(saved);
|
||||
|
||||
service.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(10L, 70L, "Task", null, null));
|
||||
|
||||
InOrder order = inOrder(projectMutations, tasks);
|
||||
order.verify(projectMutations).taskMutationContext(5L, 10L);
|
||||
order.verify(tasks).saveAndFlush(any(Task.class));
|
||||
verify(projectQueries, never()).taskContext(5L, 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusChangeLocksProjectThenTaskBeforeMutation() {
|
||||
Task task = taskForView(TaskStatus.TODO);
|
||||
when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L))
|
||||
.thenReturn(Optional.of(task));
|
||||
when(tasks.saveAndFlush(task)).thenReturn(task);
|
||||
|
||||
service.changeStatus("member@example.test", 10L, 25L, TaskStatus.IN_PROGRESS);
|
||||
|
||||
InOrder order = inOrder(projectMutations, tasks, task);
|
||||
order.verify(projectMutations).taskMutationContext(5L, 10L);
|
||||
order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L);
|
||||
order.verify(task).changeStatus(TaskStatus.IN_PROGRESS, NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commentLocksProjectThenTaskBeforeWriting() {
|
||||
Task task = mock(Task.class);
|
||||
TaskComment saved = mock(TaskComment.class);
|
||||
when(tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L))
|
||||
.thenReturn(Optional.of(task));
|
||||
when(comments.saveAndFlush(any(TaskComment.class))).thenReturn(saved);
|
||||
when(saved.getId()).thenReturn(4L);
|
||||
when(saved.getTaskId()).thenReturn(25L);
|
||||
when(saved.getAuthorUserId()).thenReturn(5L);
|
||||
when(saved.getBody()).thenReturn("Comment");
|
||||
when(saved.getCreatedAt()).thenReturn(NOW);
|
||||
|
||||
service.addComment("member@example.test", 10L, 25L, "Comment");
|
||||
|
||||
InOrder order = inOrder(projectMutations, tasks, comments);
|
||||
order.verify(projectMutations).taskMutationContext(5L, 10L);
|
||||
order.verify(tasks).findLockedByIdAndProjectIdAndDeletedAtIsNull(25L, 10L);
|
||||
order.verify(comments).saveAndFlush(any(TaskComment.class));
|
||||
}
|
||||
|
||||
private static Task taskForView(TaskStatus status) {
|
||||
Task task = mock(Task.class);
|
||||
when(task.getId()).thenReturn(25L);
|
||||
when(task.getProjectId()).thenReturn(10L);
|
||||
when(task.getAssigneeMembershipId()).thenReturn(70L);
|
||||
when(task.getTitle()).thenReturn("Task");
|
||||
when(task.getStatus()).thenReturn(status);
|
||||
when(task.getCreatorMembershipId()).thenReturn(70L);
|
||||
when(task.getAssignerMembershipId()).thenReturn(70L);
|
||||
when(task.getAssignedAt()).thenReturn(NOW);
|
||||
when(task.getCreatedAt()).thenReturn(NOW);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
|
||||
import java.util.Set;
|
||||
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;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskQueryServiceTest {
|
||||
|
||||
@Mock
|
||||
private TaskRepository tasks;
|
||||
|
||||
@InjectMocks
|
||||
private TaskQueryService taskQueries;
|
||||
|
||||
@Test
|
||||
void countsEveryCurrentTaskWhenProjectHasNoActiveMemberships() {
|
||||
given(tasks.countByProjectIdAndDeletedAtIsNull(42L)).willReturn(3L);
|
||||
|
||||
assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of())).isEqualTo(3L);
|
||||
|
||||
verify(tasks).countByProjectIdAndDeletedAtIsNull(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsCurrentTasksWhoseAssigneeIsOutsideActiveMemberships() {
|
||||
given(tasks.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).willReturn(2L);
|
||||
|
||||
assertThat(taskQueries.countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L))).isEqualTo(2L);
|
||||
|
||||
verify(tasks).countCurrentTasksAssignedOutside(42L, Set.of(7L, 9L));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user