feat(tasks): add task pages and request boundaries
This commit is contained in:
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -233,6 +234,16 @@ class TaskCreationIntegrationTest {
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
return jdbc.sql("""
|
||||
insert into app_users
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
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 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())));
|
||||
|
||||
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")));
|
||||
}
|
||||
|
||||
@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"));
|
||||
}
|
||||
|
||||
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,
|
||||
LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user