Merge commit '213a889c8f0a475abfdb06082065320379d9bc7a' into work/reports-ui
This commit is contained in:
+8
-2
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
@@ -29,12 +30,17 @@ public class ProjectController {
|
||||
|
||||
@GetMapping
|
||||
public String list(Principal principal, Model model) {
|
||||
model.addAttribute("projects", pages.listVisible(actorId(principal)));
|
||||
var actor = pages.authenticatedActor(principal.getName());
|
||||
model.addAttribute("projects", pages.listVisible(actor.userId()));
|
||||
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
|
||||
return "projects/list";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String createForm(Model model) {
|
||||
public String createForm(Principal principal, Model model) {
|
||||
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||
return "projects/form";
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public record ProjectDetail(
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String mentorName,
|
||||
String leaderName) {
|
||||
String leaderName,
|
||||
boolean canManage) {
|
||||
}
|
||||
|
||||
+30
-6
@@ -64,13 +64,16 @@ public class ProjectQueryService {
|
||||
project.startDate(),
|
||||
project.endDate(),
|
||||
displayName(project.mentorUserId()),
|
||||
displayName(project.currentLeader().internUserId()));
|
||||
displayName(project.currentLeader().internUserId()),
|
||||
project.mentorUserId() == actorUserId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProjectMemberView> members(long actorUserId, long projectId) {
|
||||
var project = visibleProject(actorUserId, projectId);
|
||||
var leaderUserId = project.currentLeader().internUserId();
|
||||
Long leaderUserId = project.status() == ProjectStatus.COMPLETED
|
||||
? null
|
||||
: project.currentLeader().internUserId();
|
||||
return project.memberships().stream()
|
||||
.map(membership -> new ProjectMemberView(
|
||||
membership.id(),
|
||||
@@ -78,7 +81,9 @@ public class ProjectQueryService {
|
||||
displayName(membership.internUserId()),
|
||||
membership.joinedAt(),
|
||||
membership.leftAt(),
|
||||
membership.isCurrent() && membership.internUserId() == leaderUserId))
|
||||
membership.isCurrent()
|
||||
&& leaderUserId != null
|
||||
&& membership.internUserId() == leaderUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -96,7 +101,22 @@ public class ProjectQueryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
|
||||
var project = visibleProject(actorUserId, projectId);
|
||||
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||
return taskContext(actorUserId, project);
|
||||
}
|
||||
|
||||
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
|
||||
requireVisibleProject(actorUserId, project);
|
||||
if (project.status() == ProjectStatus.COMPLETED) {
|
||||
return new ProjectTaskContext(
|
||||
project.id(),
|
||||
project.mentorUserId(),
|
||||
project.status().name(),
|
||||
project.startDate(),
|
||||
project.endDate(),
|
||||
null,
|
||||
List.of());
|
||||
}
|
||||
var activeMembers = project.memberships().stream()
|
||||
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
|
||||
.map(membership -> new ProjectTaskMemberView(
|
||||
@@ -141,15 +161,19 @@ public class ProjectQueryService {
|
||||
}
|
||||
|
||||
private ProjectEntity visibleProject(long actorUserId, long projectId) {
|
||||
var actor = activeActor(actorUserId);
|
||||
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||
requireVisibleProject(actorUserId, project);
|
||||
return project;
|
||||
}
|
||||
|
||||
private void requireVisibleProject(long actorUserId, ProjectEntity project) {
|
||||
var actor = activeActor(actorUserId);
|
||||
var visible = "ADMIN".equals(actor.role().name())
|
||||
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|
||||
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
|
||||
if (!visible) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
private List<ProjectEntity> visibleProjects(AccountIdentity actor, long actorUserId) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedExcepti
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
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;
|
||||
@@ -15,14 +16,17 @@ public class ProjectService {
|
||||
|
||||
private final ProjectRepository projects;
|
||||
private final AccountService accounts;
|
||||
private final ProjectQueryService queries;
|
||||
private final Clock clock;
|
||||
|
||||
public ProjectService(
|
||||
ProjectRepository projects,
|
||||
AccountService accounts,
|
||||
ProjectQueryService queries,
|
||||
Clock clock) {
|
||||
this.projects = projects;
|
||||
this.accounts = accounts;
|
||||
this.queries = queries;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@@ -61,6 +65,11 @@ public class ProjectService {
|
||||
projects.flush();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) {
|
||||
return queries.taskContext(actorUserId, lockedProject(projectId));
|
||||
}
|
||||
|
||||
private ProjectEntity lockedProject(long projectId) {
|
||||
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.lab.labtimesheet.feature.task.controller;
|
||||
|
||||
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.TaskCreateForm;
|
||||
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 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,12 @@
|
||||
package com.lab.labtimesheet.feature.task.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public final class TaskNotFoundException extends RuntimeException {
|
||||
|
||||
public TaskNotFoundException() {
|
||||
super("Task or Project was not found");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.feature.task.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public final class TaskValidationException extends RuntimeException {
|
||||
|
||||
public TaskValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.lab.labtimesheet.feature.task.model;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public record TaskProgress(int todo, int inProgress, int blocked, int done) {
|
||||
|
||||
public static TaskProgress from(Collection<TaskStatus> statuses) {
|
||||
int todo = 0;
|
||||
int inProgress = 0;
|
||||
int blocked = 0;
|
||||
int done = 0;
|
||||
for (TaskStatus status : statuses) {
|
||||
switch (status) {
|
||||
case TODO -> todo++;
|
||||
case IN_PROGRESS -> inProgress++;
|
||||
case BLOCKED -> blocked++;
|
||||
case DONE -> done++;
|
||||
}
|
||||
}
|
||||
return new TaskProgress(todo, inProgress, blocked, done);
|
||||
}
|
||||
|
||||
public int total() {
|
||||
return todo + inProgress + blocked + done;
|
||||
}
|
||||
|
||||
public int count(TaskStatus status) {
|
||||
return switch (status) {
|
||||
case TODO -> todo;
|
||||
case IN_PROGRESS -> inProgress;
|
||||
case BLOCKED -> blocked;
|
||||
case DONE -> done;
|
||||
};
|
||||
}
|
||||
|
||||
public OptionalDouble completionPercentage() {
|
||||
return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lab.labtimesheet.feature.task.model;
|
||||
|
||||
public enum TaskStatus {
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
BLOCKED,
|
||||
DONE;
|
||||
|
||||
public boolean canTransitionTo(TaskStatus target) {
|
||||
return switch (this) {
|
||||
case TODO -> target == IN_PROGRESS || target == BLOCKED;
|
||||
case IN_PROGRESS -> target == DONE || target == BLOCKED;
|
||||
case BLOCKED -> target == TODO || target == IN_PROGRESS;
|
||||
case DONE -> target == IN_PROGRESS;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateTaskCommand(
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
LocalDate dueDate) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
public record TaskAssigneeChoice(long membershipId, String displayName) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
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) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskDashboardView(
|
||||
long blockedTaskCount,
|
||||
long assignedTaskCount,
|
||||
List<TaskPriorityView> priorityTasks) {
|
||||
|
||||
public TaskDashboardView {
|
||||
priorityTasks = List.copyOf(priorityTasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskDetails(
|
||||
TaskView task,
|
||||
List<TaskCommentView> comments,
|
||||
boolean canChangeStatus,
|
||||
boolean canComment) {
|
||||
|
||||
public TaskDetails {
|
||||
comments = List.copyOf(comments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.TaskProgress;
|
||||
import java.util.List;
|
||||
|
||||
public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
|
||||
|
||||
public TaskListView {
|
||||
tasks = List.copyOf(tasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TaskPriorityView(
|
||||
String title,
|
||||
String projectName,
|
||||
TaskStatus status,
|
||||
LocalDate dueDate) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lab.labtimesheet.feature.task.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TaskView(
|
||||
long id,
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String assigneeName,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
LocalDate dueDate,
|
||||
long creatorMembershipId,
|
||||
long assignerMembershipId,
|
||||
Instant assignedAt,
|
||||
Instant createdAt) {}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.lab.labtimesheet.feature.task.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tasks")
|
||||
public class Task {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private long projectId;
|
||||
|
||||
@Column(name = "assignee_membership_id", nullable = false)
|
||||
private long assigneeMembershipId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 24)
|
||||
private TaskStatus status;
|
||||
|
||||
@Column(name = "due_date")
|
||||
private LocalDate dueDate;
|
||||
|
||||
@Column(name = "assigned_at", nullable = false)
|
||||
private Instant assignedAt;
|
||||
|
||||
@Column(name = "created_by_membership_id", nullable = false)
|
||||
private long creatorMembershipId;
|
||||
|
||||
@Column(name = "assigned_by_membership_id", nullable = false)
|
||||
private long assignerMembershipId;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private Instant deletedAt;
|
||||
|
||||
@Column(name = "deleted_by_membership_id")
|
||||
private Long deletedByMembershipId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected Task() {}
|
||||
|
||||
public Task(
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
LocalDate dueDate,
|
||||
long actorMembershipId,
|
||||
Instant now) {
|
||||
this.projectId = projectId;
|
||||
this.assigneeMembershipId = assigneeMembershipId;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.status = TaskStatus.TODO;
|
||||
this.dueDate = dueDate;
|
||||
this.assignedAt = now;
|
||||
this.creatorMembershipId = actorMembershipId;
|
||||
this.assignerMembershipId = actorMembershipId;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public void changeStatus(TaskStatus target, Instant now) {
|
||||
if (!status.canTransitionTo(target)) {
|
||||
throw new IllegalArgumentException("Task status transition is not allowed");
|
||||
}
|
||||
status = target;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getProjectId() {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
public long getAssigneeMembershipId() {
|
||||
return assigneeMembershipId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public TaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public LocalDate getDueDate() {
|
||||
return dueDate;
|
||||
}
|
||||
|
||||
public Instant getAssignedAt() {
|
||||
return assignedAt;
|
||||
}
|
||||
|
||||
public long getCreatorMembershipId() {
|
||||
return creatorMembershipId;
|
||||
}
|
||||
|
||||
public long getAssignerMembershipId() {
|
||||
return assignerMembershipId;
|
||||
}
|
||||
|
||||
public Instant getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lab.labtimesheet.feature.task.model.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "task_comments")
|
||||
public class TaskComment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "task_id", nullable = false)
|
||||
private long taskId;
|
||||
|
||||
@Column(name = "author_user_id", nullable = false)
|
||||
private long authorUserId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
private String body;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected TaskComment() {}
|
||||
|
||||
public TaskComment(long taskId, long authorUserId, String body, Instant createdAt) {
|
||||
this.taskId = taskId;
|
||||
this.authorUserId = authorUserId;
|
||||
this.body = body;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public long getAuthorUserId() {
|
||||
return authorUserId;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.task.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.entity.TaskComment;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface TaskCommentRepository extends JpaRepository<TaskComment, Long> {
|
||||
|
||||
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lab.labtimesheet.feature.task.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.model.TaskStatus;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.Task;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface TaskRepository extends JpaRepository<Task, Long> {
|
||||
|
||||
Optional<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
|
||||
|
||||
List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
|
||||
|
||||
long countByProjectIdAndDeletedAtIsNull(long projectId);
|
||||
|
||||
long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> projectIds, TaskStatus status);
|
||||
|
||||
long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
|
||||
List<Long> projectIds, List<Long> assigneeMembershipIds);
|
||||
|
||||
@Query("""
|
||||
select task
|
||||
from Task task
|
||||
where task.projectId in :projectIds
|
||||
and task.assigneeMembershipId in :assigneeMembershipIds
|
||||
and task.deletedAt is null
|
||||
order by case when task.dueDate is null then 1 else 0 end,
|
||||
task.dueDate,
|
||||
task.id
|
||||
""")
|
||||
List<Task> findPriorityTasks(
|
||||
@Param("projectIds") List<Long> projectIds,
|
||||
@Param("assigneeMembershipIds") List<Long> assigneeMembershipIds,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select count(task)
|
||||
from Task task
|
||||
where task.projectId = :projectId
|
||||
and task.deletedAt is null
|
||||
and task.assigneeMembershipId not in :activeMembershipIds
|
||||
""")
|
||||
long countCurrentTasksAssignedOutside(
|
||||
@Param("projectId") long projectId,
|
||||
@Param("activeMembershipIds") Set<Long> activeMembershipIds);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
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.dto.TaskDashboardView;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskPriorityView;
|
||||
import com.lab.labtimesheet.feature.task.model.entity.Task;
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class TaskDashboardService {
|
||||
|
||||
private static final TaskDashboardView EMPTY_DASHBOARD = new TaskDashboardView(0, 0, List.of());
|
||||
|
||||
private final TaskRepository tasks;
|
||||
private final ProjectQueryService projects;
|
||||
|
||||
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
|
||||
this.tasks = tasks;
|
||||
this.projects = projects;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskDashboardView dashboard(String actorEmail) {
|
||||
var actor = projects.authenticatedActor(actorEmail);
|
||||
List<ProjectSummary> activeProjects = projects.listVisible(actor.userId()).stream()
|
||||
.filter(project -> "ACTIVE".equals(project.status()))
|
||||
.toList();
|
||||
if ("MENTOR".equals(actor.role())) {
|
||||
return mentorDashboard(activeProjects);
|
||||
}
|
||||
if ("INTERN".equals(actor.role())) {
|
||||
return internDashboard(actor.userId(), activeProjects);
|
||||
}
|
||||
return EMPTY_DASHBOARD;
|
||||
}
|
||||
|
||||
private TaskDashboardView mentorDashboard(List<ProjectSummary> activeProjects) {
|
||||
List<Long> projectIds = activeProjects.stream().map(ProjectSummary::id).toList();
|
||||
long blocked = projectIds.isEmpty()
|
||||
? 0
|
||||
: tasks.countByProjectIdInAndStatusAndDeletedAtIsNull(projectIds, TaskStatus.BLOCKED);
|
||||
return new TaskDashboardView(blocked, 0, List.of());
|
||||
}
|
||||
|
||||
private TaskDashboardView internDashboard(long actorUserId, List<ProjectSummary> activeProjects) {
|
||||
Map<Long, ProjectSummary> currentProjects = new LinkedHashMap<>();
|
||||
Map<Long, Long> currentMemberships = new LinkedHashMap<>();
|
||||
for (ProjectSummary project : activeProjects) {
|
||||
projects.taskContext(actorUserId, project.id()).activeMembers().stream()
|
||||
.filter(member -> member.userId() == actorUserId)
|
||||
.map(ProjectTaskMemberView::membershipId)
|
||||
.findFirst()
|
||||
.ifPresent(membershipId -> {
|
||||
currentProjects.put(project.id(), project);
|
||||
currentMemberships.put(project.id(), membershipId);
|
||||
});
|
||||
}
|
||||
List<Long> projectIds = List.copyOf(currentProjects.keySet());
|
||||
List<Long> membershipIds = List.copyOf(currentMemberships.values());
|
||||
if (projectIds.isEmpty()) {
|
||||
return EMPTY_DASHBOARD;
|
||||
}
|
||||
|
||||
long assigned = tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
|
||||
projectIds, membershipIds);
|
||||
List<TaskPriorityView> priority = tasks.findPriorityTasks(
|
||||
projectIds, membershipIds, PageRequest.of(0, 5))
|
||||
.stream()
|
||||
.map(task -> priorityView(task, currentProjects.get(task.getProjectId()).name()))
|
||||
.toList();
|
||||
return new TaskDashboardView(0, assigned, priority);
|
||||
}
|
||||
|
||||
private static TaskPriorityView priorityView(Task task, String projectName) {
|
||||
return new TaskPriorityView(task.getTitle(), projectName, task.getStatus(), task.getDueDate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class TaskQueryService {
|
||||
|
||||
private final TaskRepository tasks;
|
||||
|
||||
public TaskQueryService(TaskRepository tasks) {
|
||||
this.tasks = tasks;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countCurrentTasksAssignedOutside(long projectId, Set<Long> activeMembershipIds) {
|
||||
if (activeMembershipIds.isEmpty()) {
|
||||
return tasks.countByProjectIdAndDeletedAtIsNull(projectId);
|
||||
}
|
||||
return tasks.countCurrentTasksAssignedOutside(projectId, Set.copyOf(activeMembershipIds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.lab.labtimesheet.feature.task.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
|
||||
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.exception.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
|
||||
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.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.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class TaskService {
|
||||
|
||||
private final TaskRepository tasks;
|
||||
private final TaskCommentRepository comments;
|
||||
private final ProjectQueryService projects;
|
||||
private final ProjectService projectMutations;
|
||||
private final CalendarApplicationService calendar;
|
||||
private final Clock clock;
|
||||
|
||||
public TaskService(
|
||||
TaskRepository tasks,
|
||||
TaskCommentRepository comments,
|
||||
ProjectQueryService projects,
|
||||
ProjectService projectMutations,
|
||||
CalendarApplicationService calendar,
|
||||
Clock clock) {
|
||||
this.tasks = tasks;
|
||||
this.comments = comments;
|
||||
this.projects = projects;
|
||||
this.projectMutations = projectMutations;
|
||||
this.calendar = calendar;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView create(String actorEmail, CreateTaskCommand command) {
|
||||
String title = requireTitle(command.title());
|
||||
TaskAccess access = requireMutationAccess(actorEmail, command.projectId());
|
||||
requireOpenProject(access.project());
|
||||
ProjectTaskMemberView actorMembership = requireActorMembership(
|
||||
access.project(), access.actor().userId());
|
||||
ProjectTaskMemberView assignee = requireAssigneeMembership(
|
||||
access.project(), command.assigneeMembershipId());
|
||||
if (actorMembership.membershipId() != assignee.membershipId()
|
||||
&& !Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
validateDueDate(access.project(), command.dueDate());
|
||||
|
||||
Task task = new Task(
|
||||
access.project().projectId(),
|
||||
assignee.membershipId(),
|
||||
title,
|
||||
trimToNull(command.description()),
|
||||
command.dueDate(),
|
||||
actorMembership.membershipId(),
|
||||
clock.instant());
|
||||
return view(tasks.saveAndFlush(task), assignee.displayName());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
|
||||
TaskAccess access = requireMutationAccess(actorEmail, projectId);
|
||||
if (!"ACTIVE".equals(access.project().status())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
ProjectTaskMemberView actorMembership = requireActorMembership(
|
||||
access.project(), access.actor().userId());
|
||||
Task task = requireLockedTask(projectId, taskId);
|
||||
if (task.getAssigneeMembershipId() != actorMembership.membershipId()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
if (!task.getStatus().canTransitionTo(target)) {
|
||||
throw new TaskValidationException("Task status transition is not allowed");
|
||||
}
|
||||
task.changeStatus(target, clock.instant());
|
||||
return view(tasks.saveAndFlush(task), actorMembership.displayName());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
|
||||
String normalizedBody = requireCommentBody(body);
|
||||
TaskAccess access = requireMutationAccess(actorEmail, projectId);
|
||||
if ("COMPLETED".equals(access.project().status())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
boolean owningMentor = access.actor().userId() == access.project().mentorUserId();
|
||||
boolean activeMember = access.project().activeMembers().stream()
|
||||
.anyMatch(member -> member.userId() == access.actor().userId());
|
||||
if (!owningMentor && !activeMember) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
requireLockedTask(projectId, taskId);
|
||||
|
||||
TaskComment comment = new TaskComment(
|
||||
taskId, access.actor().userId(), normalizedBody, clock.instant());
|
||||
return view(comments.saveAndFlush(comment));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskListView list(String actorEmail, long projectId) {
|
||||
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
||||
Map<Long, ProjectMemberView> members = projectMembers(access);
|
||||
List<TaskView> projectTasks = tasks.findAllByProjectIdAndDeletedAtIsNullOrderById(projectId)
|
||||
.stream()
|
||||
.map(task -> view(task, requireAssigneeName(members, task.getAssigneeMembershipId())))
|
||||
.toList();
|
||||
return new TaskListView(
|
||||
projectTasks,
|
||||
TaskProgress.from(projectTasks.stream().map(TaskView::status).toList()),
|
||||
isOpen(access.project()) && activeMembership(access) != null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskDetails details(String actorEmail, long projectId, long taskId) {
|
||||
TaskAccess access = requireReadableProject(actorEmail, projectId);
|
||||
ProjectTaskMemberView actorMembership = activeMembership(access);
|
||||
Task persistedTask = requireTask(projectId, taskId);
|
||||
TaskView task = view(
|
||||
persistedTask,
|
||||
requireAssigneeName(projectMembers(access), persistedTask.getAssigneeMembershipId()));
|
||||
List<TaskCommentView> taskComments = comments.findAllByTaskIdOrderByCreatedAtAscIdAsc(taskId)
|
||||
.stream()
|
||||
.map(TaskService::view)
|
||||
.toList();
|
||||
boolean canChangeStatus = "ACTIVE".equals(access.project().status())
|
||||
&& actorMembership != null
|
||||
&& persistedTask.getAssigneeMembershipId() == actorMembership.membershipId();
|
||||
boolean canComment = !"COMPLETED".equals(access.project().status())
|
||||
&& (access.actor().userId() == access.project().mentorUserId() || actorMembership != null);
|
||||
return new TaskDetails(task, taskComments, canChangeStatus, canComment);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TaskAssigneeChoice> assignmentChoices(String actorEmail, long projectId) {
|
||||
TaskAccess access = requireProjectAccess(actorEmail, projectId);
|
||||
requireOpenProject(access.project());
|
||||
ProjectTaskMemberView actorMembership = requireActorMembership(
|
||||
access.project(), access.actor().userId());
|
||||
if (Objects.equals(access.project().currentLeaderMembershipId(), actorMembership.membershipId())) {
|
||||
return access.project().activeMembers().stream()
|
||||
.map(member -> new TaskAssigneeChoice(member.membershipId(), member.displayName()))
|
||||
.toList();
|
||||
}
|
||||
return List.of(new TaskAssigneeChoice(
|
||||
actorMembership.membershipId(), actorMembership.displayName()));
|
||||
}
|
||||
|
||||
private TaskAccess requireProjectAccess(String actorEmail, long projectId) {
|
||||
try {
|
||||
ProjectActorView actor = projects.authenticatedActor(actorEmail);
|
||||
return new TaskAccess(actor, projects.taskContext(actor.userId(), projectId));
|
||||
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private TaskAccess requireMutationAccess(String actorEmail, long projectId) {
|
||||
try {
|
||||
ProjectActorView actor = projects.authenticatedActor(actorEmail);
|
||||
return new TaskAccess(actor, projectMutations.taskMutationContext(actor.userId(), projectId));
|
||||
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private TaskAccess requireReadableProject(String actorEmail, long projectId) {
|
||||
TaskAccess access = requireProjectAccess(actorEmail, projectId);
|
||||
boolean historicalIntern = "INTERN".equals(access.actor().role())
|
||||
&& access.project().activeMembers().stream()
|
||||
.noneMatch(member -> member.userId() == access.actor().userId());
|
||||
if (historicalIntern && !"COMPLETED".equals(access.project().status())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
private Map<Long, ProjectMemberView> projectMembers(TaskAccess access) {
|
||||
try {
|
||||
return projects.members(access.actor().userId(), access.project().projectId()).stream()
|
||||
.collect(Collectors.toUnmodifiableMap(ProjectMemberView::membershipId, Function.identity()));
|
||||
} catch (ProjectAccessDeniedException | ProjectRuleViolationException exception) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireAssigneeName(Map<Long, ProjectMemberView> members, long membershipId) {
|
||||
ProjectMemberView member = members.get(membershipId);
|
||||
if (member == null) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
return member.displayName();
|
||||
}
|
||||
|
||||
private static ProjectTaskMemberView activeMembership(TaskAccess access) {
|
||||
return access.project().activeMembers().stream()
|
||||
.filter(member -> member.userId() == access.actor().userId())
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private Task requireTask(long projectId, long taskId) {
|
||||
return tasks.findByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId)
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private Task requireLockedTask(long projectId, long taskId) {
|
||||
return tasks.findLockedByIdAndProjectIdAndDeletedAtIsNull(taskId, projectId)
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private static void requireOpenProject(ProjectTaskContext project) {
|
||||
if (!isOpen(project)) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOpen(ProjectTaskContext project) {
|
||||
return "PLANNED".equals(project.status()) || "ACTIVE".equals(project.status());
|
||||
}
|
||||
|
||||
private static ProjectTaskMemberView requireActorMembership(ProjectTaskContext project, long userId) {
|
||||
return project.activeMembers().stream()
|
||||
.filter(member -> member.userId() == userId)
|
||||
.findFirst()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private static ProjectTaskMemberView requireAssigneeMembership(ProjectTaskContext project, long membershipId) {
|
||||
return project.activeMembers().stream()
|
||||
.filter(member -> member.membershipId() == membershipId)
|
||||
.findFirst()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private void validateDueDate(ProjectTaskContext project, LocalDate dueDate) {
|
||||
if (dueDate == null) {
|
||||
return;
|
||||
}
|
||||
if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) {
|
||||
throw new TaskValidationException("Due date must be within Project dates");
|
||||
}
|
||||
if (calendar.isGlobalDayOff(dueDate)) {
|
||||
throw new TaskValidationException("Due date cannot be a current global day off");
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskView view(Task task, String assigneeName) {
|
||||
return new TaskView(
|
||||
task.getId(),
|
||||
task.getProjectId(),
|
||||
task.getAssigneeMembershipId(),
|
||||
assigneeName,
|
||||
task.getTitle(),
|
||||
task.getDescription(),
|
||||
task.getStatus(),
|
||||
task.getDueDate(),
|
||||
task.getCreatorMembershipId(),
|
||||
task.getAssignerMembershipId(),
|
||||
task.getAssignedAt(),
|
||||
task.getCreatedAt());
|
||||
}
|
||||
|
||||
private static TaskCommentView view(TaskComment comment) {
|
||||
return new TaskCommentView(
|
||||
comment.getId(),
|
||||
comment.getTaskId(),
|
||||
comment.getAuthorUserId(),
|
||||
comment.getBody(),
|
||||
comment.getCreatedAt());
|
||||
}
|
||||
|
||||
private static String requireTitle(String title) {
|
||||
String trimmed = trimToNull(title);
|
||||
if (trimmed == null || trimmed.length() > 200) {
|
||||
throw new TaskValidationException("Title is required and must not exceed 200 characters");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String requireCommentBody(String body) {
|
||||
String trimmed = trimToNull(body);
|
||||
if (trimmed == null) {
|
||||
throw new TaskValidationException("Comment body is required");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private record TaskAccess(ProjectActorView actor, ProjectTaskContext project) {}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
|
||||
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
|
||||
</table>
|
||||
<form method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
||||
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Projects</h1>
|
||||
<a href="/projects/new">Create Project</a>
|
||||
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
|
||||
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
||||
<table th:unless="${#lists.isEmpty(projects)}">
|
||||
<caption>Authorized Projects</caption>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
|
||||
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
|
||||
</table>
|
||||
<form method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
||||
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
||||
</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 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>Assignee: <strong th:text="${details.task.assigneeName}">Assignee</strong></p>
|
||||
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
|
||||
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}">—</span></p>
|
||||
|
||||
<form th:if="${details.canChangeStatus}" 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 th:if="${details.canComment}" 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,33 @@
|
||||
<!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 th:if="${taskList.canCreate}"><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">Assignee</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.assigneeName}">Assignee</td>
|
||||
<td th:text="${task.status}">TODO</td>
|
||||
<td th:text="${task.dueDate ?: '—'}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+39
-5
@@ -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
|
||||
|
||||
+42
-2
@@ -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,
|
||||
|
||||
+59
@@ -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))));
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+563
@@ -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();
|
||||
}
|
||||
}
|
||||
+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