feat(tasks): add task pages and request boundaries
This commit is contained in:
@@ -12,7 +12,7 @@ PostgreSQL-backed Task operations preserve generic same-Project membership actor
|
|||||||
|
|
||||||
## Test method
|
## Test method
|
||||||
|
|
||||||
Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations.
|
Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations.
|
||||||
|
|
||||||
## Hand-derived expected result
|
## Hand-derived expected result
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
**Observed result**
|
**Observed result**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
|||||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
./mvnw test
|
./mvnw test
|
||||||
|
|
||||||
[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Test Evidence: Task pages and server-side request boundaries
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012`
|
||||||
|
- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest`
|
||||||
|
- **Implementation commit:** `pending`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=TaskControllerTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] TaskControllerTest.java:[28,13] cannot find symbol
|
||||||
|
symbol: class TaskController
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run.
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=TaskControllerTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with approved sandbox escalation for Mockito Java 25 self-attach.
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw test
|
||||||
|
|
||||||
|
[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite ran with approved escalation for OrbStack and Mockito self-attach.
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.lab.labtimesheet.tasks;
|
||||||
|
|
||||||
|
public record TaskAssigneeChoice(long membershipId, String displayName) {}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.lab.labtimesheet.tasks;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class TaskController {
|
||||||
|
|
||||||
|
private final TaskService taskService;
|
||||||
|
|
||||||
|
public TaskController(TaskService taskService) {
|
||||||
|
this.taskService = taskService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/projects/{projectId}/tasks")
|
||||||
|
String list(Authentication authentication, @PathVariable long projectId, Model model) {
|
||||||
|
TaskListView taskList = taskService.list(authentication.getName(), projectId);
|
||||||
|
model.addAttribute("projectId", projectId);
|
||||||
|
model.addAttribute("taskList", taskList);
|
||||||
|
model.addAttribute("progressLabel", progressLabel(taskList.progress()));
|
||||||
|
return "tasks/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/projects/{projectId}/tasks/new")
|
||||||
|
String createForm(Authentication authentication, @PathVariable long projectId, Model model) {
|
||||||
|
model.addAttribute("taskForm", new TaskCreateForm("", "", null, null));
|
||||||
|
populateForm(authentication.getName(), projectId, model);
|
||||||
|
return "tasks/form";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/projects/{projectId}/tasks")
|
||||||
|
String create(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable long projectId,
|
||||||
|
@Valid @ModelAttribute("taskForm") TaskCreateForm form,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
Model model) {
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
populateForm(authentication.getName(), projectId, model);
|
||||||
|
return "tasks/form";
|
||||||
|
}
|
||||||
|
TaskView task = taskService.create(
|
||||||
|
authentication.getName(),
|
||||||
|
new CreateTaskCommand(
|
||||||
|
projectId,
|
||||||
|
form.assigneeMembershipId(),
|
||||||
|
form.title(),
|
||||||
|
form.description(),
|
||||||
|
form.dueDate()));
|
||||||
|
return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/projects/{projectId}/tasks/{taskId}")
|
||||||
|
String details(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable long projectId,
|
||||||
|
@PathVariable long taskId,
|
||||||
|
Model model) {
|
||||||
|
model.addAttribute("projectId", projectId);
|
||||||
|
model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId));
|
||||||
|
model.addAttribute("statuses", TaskStatus.values());
|
||||||
|
return "tasks/detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/projects/{projectId}/tasks/{taskId}/status")
|
||||||
|
String changeStatus(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable long projectId,
|
||||||
|
@PathVariable long taskId,
|
||||||
|
@RequestParam TaskStatus status) {
|
||||||
|
taskService.changeStatus(authentication.getName(), projectId, taskId, status);
|
||||||
|
return detailsRedirect(projectId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/projects/{projectId}/tasks/{taskId}/comments")
|
||||||
|
String addComment(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable long projectId,
|
||||||
|
@PathVariable long taskId,
|
||||||
|
@RequestParam String body) {
|
||||||
|
taskService.addComment(authentication.getName(), projectId, taskId, body);
|
||||||
|
return detailsRedirect(projectId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateForm(String actorEmail, long projectId, Model model) {
|
||||||
|
model.addAttribute("projectId", projectId);
|
||||||
|
model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String detailsRedirect(long projectId, long taskId) {
|
||||||
|
return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String progressLabel(TaskProgress progress) {
|
||||||
|
return progress.completionPercentage().isEmpty()
|
||||||
|
? "N/A"
|
||||||
|
: String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.lab.labtimesheet.tasks;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
|
public record TaskCreateForm(
|
||||||
|
@NotBlank @Size(max = 200) String title,
|
||||||
|
String description,
|
||||||
|
@NotNull Long assigneeMembershipId,
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {}
|
||||||
@@ -153,6 +153,32 @@ public class TaskService {
|
|||||||
return new TaskDetails(task, comments);
|
return new TaskDetails(task, comments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
||||||
|
Actor actor = requireActiveActor(actorEmail);
|
||||||
|
requireOpenProject(projectId);
|
||||||
|
long actorMembershipId = requireActorMembership(projectId, actor.id());
|
||||||
|
boolean leader = isCurrentLeader(projectId, actorMembershipId);
|
||||||
|
return jdbc.sql("""
|
||||||
|
select m.id, u.display_name
|
||||||
|
from project_memberships m
|
||||||
|
join app_users u on u.id = m.intern_user_id
|
||||||
|
join intern_profiles i on i.user_id = m.intern_user_id
|
||||||
|
where m.project_id = :projectId
|
||||||
|
and m.left_at is null
|
||||||
|
and u.account_status = 'ACTIVE'
|
||||||
|
and i.internship_status = 'ACTIVE'
|
||||||
|
and (:leader or m.id = :actorMembershipId)
|
||||||
|
order by m.id
|
||||||
|
""")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("leader", leader)
|
||||||
|
.param("actorMembershipId", actorMembershipId)
|
||||||
|
.query((rs, rowNum) -> new TaskAssigneeChoice(
|
||||||
|
rs.getLong("id"), rs.getString("display_name")))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
private Actor requireActiveActor(String email) {
|
private Actor requireActiveActor(String email) {
|
||||||
Actor actor = requireReadableActor(email);
|
Actor actor = requireReadableActor(email);
|
||||||
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
|
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title th:text="${details.task.title}">Task</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1 th:text="${details.task.title}">Task</h1>
|
||||||
|
<p th:text="${details.task.description ?: 'No description'}">No description</p>
|
||||||
|
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
|
||||||
|
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}">—</span></p>
|
||||||
|
|
||||||
|
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
|
||||||
|
<label for="status">New status</label>
|
||||||
|
<select id="status" name="status" required>
|
||||||
|
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Change status</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section aria-labelledby="comments-heading">
|
||||||
|
<h2 id="comments-heading">Comments</h2>
|
||||||
|
<ol>
|
||||||
|
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
|
||||||
|
</ol>
|
||||||
|
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
|
||||||
|
<label for="body">Comment</label>
|
||||||
|
<textarea id="body" name="body" required></textarea>
|
||||||
|
<button type="submit">Add comment</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Create Task</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Create Task</h1>
|
||||||
|
<form method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
|
||||||
|
<div>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="title" type="text" maxlength="200" required th:field="*{title}">
|
||||||
|
<p role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" th:field="*{description}"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="assigneeMembershipId">Assignee</label>
|
||||||
|
<select id="assigneeMembershipId" required th:field="*{assigneeMembershipId}">
|
||||||
|
<option value="">Select an assignee</option>
|
||||||
|
<option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option>
|
||||||
|
</select>
|
||||||
|
<p role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="dueDate">Due date</label>
|
||||||
|
<input id="dueDate" type="date" th:field="*{dueDate}">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create Task</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Project tasks</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Project tasks</h1>
|
||||||
|
<p>Progress: <strong th:text="${progressLabel}">N/A</strong></p>
|
||||||
|
<dl>
|
||||||
|
<dt>TODO</dt><dd th:text="${taskList.progress.todo}">0</dd>
|
||||||
|
<dt>IN_PROGRESS</dt><dd th:text="${taskList.progress.inProgress}">0</dd>
|
||||||
|
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
|
||||||
|
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
|
||||||
|
</dl>
|
||||||
|
<p><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
|
||||||
|
<table>
|
||||||
|
<caption>Current non-deleted Tasks</caption>
|
||||||
|
<thead><tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="task : ${taskList.tasks}">
|
||||||
|
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
|
||||||
|
<td th:text="${task.status}">TODO</td>
|
||||||
|
<td th:text="${task.dueDate ?: '—'}">—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,7 @@ import com.lab.labtimesheet.tasks.CreateTaskCommand;
|
|||||||
import com.lab.labtimesheet.tasks.TaskCommentView;
|
import com.lab.labtimesheet.tasks.TaskCommentView;
|
||||||
import com.lab.labtimesheet.tasks.TaskDetails;
|
import com.lab.labtimesheet.tasks.TaskDetails;
|
||||||
import com.lab.labtimesheet.tasks.TaskListView;
|
import com.lab.labtimesheet.tasks.TaskListView;
|
||||||
|
import com.lab.labtimesheet.tasks.TaskAssigneeChoice;
|
||||||
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
||||||
import com.lab.labtimesheet.tasks.TaskService;
|
import com.lab.labtimesheet.tasks.TaskService;
|
||||||
import com.lab.labtimesheet.tasks.TaskStatus;
|
import com.lab.labtimesheet.tasks.TaskStatus;
|
||||||
@@ -233,6 +234,16 @@ class TaskCreationIntegrationTest {
|
|||||||
.isInstanceOf(TaskNotFoundException.class);
|
.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) {
|
private long insertUser(String email, String role) {
|
||||||
return jdbc.sql("""
|
return jdbc.sql("""
|
||||||
insert into app_users
|
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