Merge commit '213a889c8f0a475abfdb06082065320379d9bc7a' into work/reports-ui

This commit is contained in:
sechmachine
2026-08-15 01:24:54 +07:00
54 changed files with 3417 additions and 24 deletions
@@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.project.controller;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -13,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
@@ -41,7 +44,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
30L,
"Intern Portal Refresh",
@@ -52,11 +56,26 @@ class ProjectControllerTest {
mvc.perform(get("/projects"))
.andExpect(status().isOk())
.andExpect(view().name("projects/list"))
.andExpect(model().attributeExists("projects"));
.andExpect(model().attributeExists("projects"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("Create Project")));
verify(pages).listVisible(10L);
}
@Test
@WithMockUser(username = "member@example.test")
void nonMentorProjectListOmitsTheCreateLink() throws Exception {
when(pages.authenticatedActor("member@example.test"))
.thenReturn(new ProjectActorView(20L, "INTERN"));
when(pages.listVisible(20L)).thenReturn(List.of());
mvc.perform(get("/projects"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Create Project"))));
}
@Test
@WithMockUser(username = "member@example.test")
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
@@ -79,16 +98,31 @@ class ProjectControllerTest {
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 30),
"Mentor",
"Leader"));
"Leader",
false));
when(pages.members(20L, 30L)).thenReturn(List.of());
when(pages.leadership(20L, 30L)).thenReturn(List.of());
mvc.perform(get("/projects/30/members"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"));
.andExpect(view().name("projects/members"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Add member"))));
mvc.perform(get("/projects/30/leadership"))
.andExpect(status().isOk())
.andExpect(view().name("projects/leadership"));
.andExpect(view().name("projects/leadership"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Change Leader"))));
}
@Test
@WithMockUser(username = "member@example.test")
void nonMentorCannotOpenProjectCreationForm() throws Exception {
when(pages.authenticatedActor("member@example.test"))
.thenReturn(new ProjectActorView(20L, "INTERN"));
mvc.perform(get("/projects/new"))
.andExpect(status().isNotFound());
}
@Test
@@ -1,6 +1,7 @@
package com.lab.labtimesheet.feature.project.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -144,11 +145,14 @@ class ProjectServiceIntegrationTest {
assertEquals(List.of(), projectPages.listVisible(unrelatedId));
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role());
var taskContext = projectPages.taskContext(memberId, projectId);
var taskContext = projectService.taskMutationContext(memberId, projectId);
assertEquals(mentorId, taskContext.mentorUserId());
assertEquals("PLANNED", taskContext.status());
assertEquals(2, taskContext.activeMembers().size());
assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId());
assertEquals(taskContext, projectPages.taskContext(memberId, projectId));
assertThrows(ProjectAccessDeniedException.class,
() -> projectService.taskMutationContext(otherMentorId, projectId));
jdbc.update("""
update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ?
""", dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId);
@@ -169,12 +173,48 @@ class ProjectServiceIntegrationTest {
entityManager.clear();
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream()
assertTrue(projectService.taskMutationContext(memberId, projectId).activeMembers().stream()
.noneMatch(member -> member.userId() == memberId));
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
}
@Test
void completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader() {
long mentorId = user("mentor-history@example.test", "MENTOR");
long leaderId = intern("leader-history@example.test", "I012");
long memberId = intern("member-history@example.test", "I013");
long projectId = createProject(mentorId, leaderId, "Completed history");
projectService.addMember(mentorId, projectId, memberId);
var activatedAt = dbTime(NOW.plusSeconds(30));
var completedAt = dbTime(NOW.plusSeconds(60));
jdbc.update("""
update project_leadership_terms
set ended_at = ?, ended_by_mentor_user_id = ?
where project_id = ? and ended_at is null
""", completedAt, mentorId, projectId);
jdbc.update("""
update project_memberships
set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ?
where project_id = ? and left_at is null
""", completedAt, mentorId, completedAt, projectId);
jdbc.update("""
update projects
set status = 'COMPLETED', activated_at = ?, completed_at = ?, updated_at = ?
where id = ?
""", activatedAt, completedAt, completedAt, projectId);
entityManager.clear();
var taskContext = projectPages.taskContext(memberId, projectId);
assertEquals("COMPLETED", taskContext.status());
assertNull(taskContext.currentLeaderMembershipId());
assertEquals(List.of(), taskContext.activeMembers());
var members = projectPages.members(memberId, projectId);
assertEquals(2, members.size());
assertTrue(members.stream().allMatch(member -> member.leftAt() != null));
assertTrue(members.stream().noneMatch(member -> member.currentLeader()));
}
private long createProject(long mentorId, long leaderId, String name) {
return projectService.create(
mentorId,
@@ -0,0 +1,59 @@
package com.lab.labtimesheet.feature.project.service;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ProjectTaskMutationContextTest {
@Mock
private ProjectRepository projects;
@Mock
private AccountService accounts;
@Mock
private ProjectQueryService queries;
@Mock
private ProjectEntity project;
@Test
void loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext() {
long actorUserId = 20L;
long projectId = 30L;
var expected = new ProjectTaskContext(
projectId,
10L,
"ACTIVE",
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 30),
40L,
List.of());
var service = new ProjectService(projects, accounts, queries, Clock.systemUTC());
when(projects.findLockedById(projectId)).thenReturn(Optional.of(project));
when(queries.taskContext(actorUserId, project)).thenReturn(expected);
var actual = service.taskMutationContext(actorUserId, projectId);
assertSame(expected, actual);
verify(projects).findLockedById(projectId);
verify(projects, never()).findById(projectId);
verify(queries).taskContext(actorUserId, project);
}
}
@@ -0,0 +1,180 @@
package com.lab.labtimesheet.feature.task.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
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;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(TaskController.class)
class TaskControllerTest {
private static final String ACTOR_EMAIL = "member@example.test";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private TaskService taskService;
@Test
void taskListRequiresAuthentication() throws Exception {
mockMvc.perform(get("/projects/10/tasks"))
.andExpect(status().isUnauthorized());
verifyNoInteractions(taskService);
}
@Test
void emptyTaskListRendersNotApplicableProgress() throws Exception {
given(taskService.list(ACTOR_EMAIL, 10L))
.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.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
void guessedTaskIdentifierReturnsNotFoundWithoutRenderingDetails() throws Exception {
given(taskService.details(ACTOR_EMAIL, 10L, 999L)).willThrow(new TaskNotFoundException());
mockMvc.perform(get("/projects/10/tasks/999").with(user(ACTOR_EMAIL)))
.andExpect(status().isNotFound());
}
@Test
void validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask() throws Exception {
given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)))
.willReturn(task(25L));
mockMvc.perform(post("/projects/10/tasks")
.with(user(ACTOR_EMAIL))
.with(csrf())
.param("title", "Draft")
.param("description", "Notes")
.param("assigneeMembershipId", "7")
.param("dueDate", "2026-08-20"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/projects/10/tasks/25"));
ArgumentCaptor<CreateTaskCommand> command = ArgumentCaptor.forClass(CreateTaskCommand.class);
verify(taskService).create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), command.capture());
assertThat(command.getValue()).isEqualTo(new CreateTaskCommand(
10L, 7L, "Draft", "Notes", LocalDate.of(2026, 8, 20)));
}
@Test
void blankCreateFormRendersValidationErrorWithoutWriting() throws Exception {
given(taskService.assignmentChoices(ACTOR_EMAIL, 10L))
.willReturn(List.of(new TaskAssigneeChoice(7L, "Member")));
mockMvc.perform(post("/projects/10/tasks")
.with(user(ACTOR_EMAIL))
.with(csrf())
.param("title", " ")
.param("assigneeMembershipId", "7"))
.andExpect(status().isOk())
.andExpect(view().name("tasks/form"))
.andExpect(model().attributeHasFieldErrors("taskForm", "title"));
verify(taskService, org.mockito.Mockito.never())
.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class));
}
@Test
void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception {
given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS))
.willReturn(task(25L));
given(taskService.addComment(ACTOR_EMAIL, 10L, 25L, "Update"))
.willReturn(new TaskCommentView(3L, 25L, 5L, "Update", Instant.parse("2026-08-14T10:00:00Z")));
mockMvc.perform(post("/projects/10/tasks/25/status")
.with(user(ACTOR_EMAIL))
.with(csrf())
.param("status", "IN_PROGRESS"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/projects/10/tasks/25"));
mockMvc.perform(post("/projects/10/tasks/25/comments")
.with(user(ACTOR_EMAIL))
.with(csrf())
.param("body", "Update"))
.andExpect(status().is3xxRedirection())
.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, "Member Name", "Draft", "Notes", TaskStatus.TODO,
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
}
}
@@ -0,0 +1,61 @@
package com.lab.labtimesheet.feature.task.model;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class TaskDomainRulesTest {
private static final Map<TaskStatus, Set<TaskStatus>> ALLOWED_TRANSITIONS = Map.of(
TaskStatus.TODO, Set.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED),
TaskStatus.IN_PROGRESS, Set.of(TaskStatus.DONE, TaskStatus.BLOCKED),
TaskStatus.BLOCKED, Set.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS),
TaskStatus.DONE, Set.of(TaskStatus.IN_PROGRESS));
@ParameterizedTest
@MethodSource("allStatusTransitions")
void acceptsOnlyTheFixedStatusGraph(TaskStatus current, TaskStatus target, boolean expected) {
assertThat(current.canTransitionTo(target)).isEqualTo(expected);
}
@Test
void reportsNoPercentageForAProjectWithoutTasks() {
TaskProgress progress = TaskProgress.from(List.of());
assertThat(progress.completionPercentage()).isEmpty();
assertThat(progress.total()).isZero();
assertThat(progress.count(TaskStatus.DONE)).isZero();
}
@Test
void countsStatusesAndDonePercentageFromCurrentTasks() {
TaskProgress progress = TaskProgress.from(List.of(
TaskStatus.TODO,
TaskStatus.IN_PROGRESS,
TaskStatus.DONE,
TaskStatus.DONE));
assertThat(progress.completionPercentage()).hasValue(50);
assertThat(progress.total()).isEqualTo(4);
assertThat(progress.count(TaskStatus.TODO)).isEqualTo(1);
assertThat(progress.count(TaskStatus.IN_PROGRESS)).isEqualTo(1);
assertThat(progress.count(TaskStatus.BLOCKED)).isZero();
assertThat(progress.count(TaskStatus.DONE)).isEqualTo(2);
}
private static Stream<Arguments> allStatusTransitions() {
return Stream.of(TaskStatus.values())
.flatMap(current -> Stream.of(TaskStatus.values())
.map(target -> Arguments.of(
current,
target,
ALLOWED_TRANSITIONS.get(current).contains(target))));
}
}
@@ -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();
}
}
}
@@ -0,0 +1,563 @@
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.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;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class TaskCreationIntegrationTest {
private static final LocalDate PROJECT_START = LocalDate.of(2026, 8, 1);
private static final LocalDate PROJECT_END = LocalDate.of(2026, 8, 31);
@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;
@BeforeEach
void setUpProject() {
long mentorId = insertUser("mentor@example.test", "MENTOR");
long leaderId = insertIntern("leader@example.test");
long memberId = insertIntern("member@example.test");
projectId = insertProject(mentorId, "PLANNED");
leaderMembershipId = insertMembership(projectId, leaderId, mentorId);
memberMembershipId = insertMembership(projectId, memberId, mentorId);
jdbc.sql("""
insert into project_leadership_terms
(project_id, membership_id, appointed_by_mentor_user_id)
values (:projectId, :membershipId, :mentorId)
""")
.param("projectId", projectId)
.param("membershipId", leaderMembershipId)
.param("mentorId", mentorId)
.update();
}
@Test
void activeMemberCreatesOnlyASelfAssignedTaskWithEqualActors() {
TaskView task = taskService.create(
"member@example.test",
new CreateTaskCommand(projectId, memberMembershipId, " Draft results ", " notes ", PROJECT_START));
assertThat(task.status()).isEqualTo(TaskStatus.TODO);
assertThat(task.title()).isEqualTo("Draft results");
assertThat(task.description()).isEqualTo("notes");
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",
new CreateTaskCommand(projectId, leaderMembershipId, "Forbidden", null, null)))
.isInstanceOf(TaskNotFoundException.class);
assertThat(taskCount()).isEqualTo(1);
}
@Test
void currentLeaderCreatesForAnotherActiveSameProjectMember() {
TaskView task = taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Review results", null, PROJECT_END));
assertThat(task.creatorMembershipId()).isEqualTo(leaderMembershipId);
assertThat(task.assignerMembershipId()).isEqualTo(leaderMembershipId);
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
}
@Test
void rejectsCrossProjectAndInactiveAssigneesWithoutWriting() {
long mentorId = userId("mentor@example.test");
long outsiderId = insertIntern("outsider@example.test");
long otherProjectId = insertProject(mentorId, "PLANNED");
long otherMembershipId = insertMembership(otherProjectId, outsiderId, mentorId);
jdbc.sql("update project_memberships set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId where id = :id")
.param("mentorId", mentorId)
.param("id", memberMembershipId)
.update();
assertThatThrownBy(() -> taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, otherMembershipId, "Cross project", null, null)))
.isInstanceOf(TaskNotFoundException.class);
assertThatThrownBy(() -> taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Inactive", null, null)))
.isInstanceOf(TaskNotFoundException.class);
assertThat(taskCount()).isZero();
}
@Test
void acceptsProjectBoundaryDueDatesAndRejectsOutsideOrCurrentDayOff() {
taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Start boundary", null, PROJECT_START));
taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "End boundary", null, PROJECT_END));
insertDayOff(LocalDate.of(2026, 8, 15));
assertThatThrownBy(() -> taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Before", null, PROJECT_START.minusDays(1))))
.isInstanceOf(TaskValidationException.class);
assertThatThrownBy(() -> taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "After", null, PROJECT_END.plusDays(1))))
.isInstanceOf(TaskValidationException.class);
assertThatThrownBy(() -> taskService.create(
"leader@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Day off", null, LocalDate.of(2026, 8, 15))))
.isInstanceOf(TaskValidationException.class);
assertThat(taskCount()).isEqualTo(2);
}
@Test
void onlyCurrentAssigneeChangesStatusOnAnActiveProject() {
TaskView task = taskService.create(
"member@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Run experiment", null, null));
assertThatThrownBy(() -> taskService.changeStatus(
"member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS))
.isInstanceOf(TaskNotFoundException.class);
activateProject();
assertThatThrownBy(() -> taskService.changeStatus(
"leader@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS))
.isInstanceOf(TaskNotFoundException.class);
TaskView inProgress = taskService.changeStatus(
"member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS);
assertThat(inProgress.status()).isEqualTo(TaskStatus.IN_PROGRESS);
assertThatThrownBy(() -> taskService.changeStatus(
"member@example.test", projectId, task.id(), TaskStatus.TODO))
.isInstanceOf(TaskValidationException.class);
}
@Test
void activeMemberAndOwningMentorAppendCommentsUntilProjectCompletion() {
TaskView task = taskService.create(
"member@example.test",
new CreateTaskCommand(projectId, memberMembershipId, "Discuss results", null, null));
insertIntern("outsider@example.test");
TaskCommentView memberComment = taskService.addComment(
"member@example.test", projectId, task.id(), " First note ");
TaskCommentView mentorComment = taskService.addComment(
"mentor@example.test", projectId, task.id(), "Mentor note");
assertThat(memberComment.body()).isEqualTo("First note");
assertThat(mentorComment.authorUserId()).isEqualTo(userId("mentor@example.test"));
assertThatThrownBy(() -> taskService.addComment(
"outsider@example.test", projectId, task.id(), "Forbidden"))
.isInstanceOf(TaskNotFoundException.class);
assertThatThrownBy(() -> taskService.addComment(
"member@example.test", projectId, task.id(), " "))
.isInstanceOf(TaskValidationException.class);
completeProject();
assertThatThrownBy(() -> taskService.addComment(
"mentor@example.test", projectId, task.id(), "Too late"))
.isInstanceOf(TaskNotFoundException.class);
assertThat(commentCount()).isEqualTo(2);
}
@Test
void authorizedListsAndDetailsExcludeDeletedTasksAndReportEmptyAsNotApplicable() {
TaskView todo = createMemberTask("Todo");
TaskView active = createMemberTask("Active");
TaskView blocked = createMemberTask("Blocked");
TaskView done = createMemberTask("Done");
TaskView deleted = createMemberTask("Deleted");
setStatus(active.id(), TaskStatus.IN_PROGRESS);
setStatus(blocked.id(), TaskStatus.BLOCKED);
setStatus(done.id(), TaskStatus.DONE);
softDelete(deleted.id());
taskService.addComment("member@example.test", projectId, todo.id(), "Visible comment");
TaskListView list = taskService.list("member@example.test", projectId);
TaskDetails details = taskService.details("mentor@example.test", projectId, todo.id());
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);
assertThat(list.progress().count(TaskStatus.BLOCKED)).isEqualTo(1);
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();
}
@Test
void directAndCrossProjectTaskIdentifiersDoNotDiscloseRecords() {
TaskView task = createMemberTask("Private task");
long otherProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
assertThatThrownBy(() -> taskService.details(
"mentor@example.test", otherProjectId, task.id()))
.isInstanceOf(TaskNotFoundException.class);
assertThatThrownBy(() -> taskService.details(
"outsider@example.test", projectId, task.id()))
.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))
.extracting(TaskAssigneeChoice::membershipId)
.containsExactly(memberMembershipId);
assertThat(taskService.assignmentChoices("leader@example.test", projectId))
.extracting(TaskAssigneeChoice::membershipId)
.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
(email, display_name, password_hash, global_role, account_status, activated_at)
values (:email, :email, 'hash', :role, 'ACTIVE', current_timestamp)
returning id
""")
.param("email", email)
.param("role", role)
.query(Long.class)
.single();
}
private long insertIntern(String email) {
long userId = insertUser(email, "INTERN");
jdbc.sql("""
insert into intern_profiles
(user_id, student_code, internship_start_date, internship_end_date,
internship_status, activated_at)
values (:userId, :studentCode, date '2026-01-01', date '2026-12-31',
'ACTIVE', current_timestamp)
""")
.param("userId", userId)
.param("studentCode", "S" + userId)
.update();
return userId;
}
private long insertProject(long mentorId, String status) {
return jdbc.sql("""
insert into projects
(mentor_user_id, name, status, start_date, end_date, activated_at)
values (:mentorId, 'Project', :status, :startDate, :endDate,
case when :status = 'ACTIVE' then current_timestamp else null end)
returning id
""")
.param("mentorId", mentorId)
.param("status", status)
.param("startDate", PROJECT_START)
.param("endDate", PROJECT_END)
.query(Long.class)
.single();
}
private long insertMembership(long targetProjectId, long internId, long mentorId) {
return jdbc.sql("""
insert into project_memberships (project_id, intern_user_id, added_by_user_id)
values (:projectId, :internId, :mentorId)
returning id
""")
.param("projectId", targetProjectId)
.param("internId", internId)
.param("mentorId", mentorId)
.query(Long.class)
.single();
}
private void insertDayOff(LocalDate date) {
long mentorId = userId("mentor@example.test");
jdbc.sql("""
insert into global_calendar_events
(calendar_date, name, source, is_day_off, created_by_user_id, updated_by_user_id)
values (:date, 'Day off', 'CUSTOM', true, :userId, :userId)
""")
.param("date", date)
.param("userId", mentorId)
.update();
}
private long userId(String email) {
return jdbc.sql("select id from app_users where email = :email")
.param("email", email)
.query(Long.class)
.single();
}
private long taskCount() {
return jdbc.sql("select count(*) from tasks").query(Long.class).single();
}
private long commentCount() {
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",
new CreateTaskCommand(projectId, memberMembershipId, title, null, null));
}
private void activateProject() {
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,
completed_at = current_timestamp
where id = :id
""")
.param("id", projectId)
.update();
entityManager.clear();
}
private void setStatus(long taskId, TaskStatus status) {
jdbc.sql("update tasks set status = :status where id = :id")
.param("status", status.name())
.param("id", taskId)
.update();
entityManager.clear();
}
private void softDelete(long taskId) {
jdbc.sql("""
update tasks
set deleted_at = current_timestamp, deleted_by_membership_id = :membershipId
where id = :id
""")
.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();
}
}
@@ -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);
}
}
@@ -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));
}
}