feat(tasks): persist iteration 1 task workflow
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateTaskCommand(
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
LocalDate dueDate) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskDetails(TaskView task, List<TaskCommentView> comments) {
|
||||
|
||||
public TaskDetails {
|
||||
comments = List.copyOf(comments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskListView(List<TaskView> tasks, TaskProgress progress) {
|
||||
|
||||
public TaskListView {
|
||||
tasks = List.copyOf(tasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class TaskService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
|
||||
public TaskService(JdbcClient jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView create(String actorEmail, CreateTaskCommand command) {
|
||||
String title = requireTitle(command.title());
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
Project project = requireOpenProject(command.projectId());
|
||||
long actorMembershipId = requireActorMembership(project.id(), actor.id());
|
||||
requireAssigneeMembership(project.id(), command.assigneeMembershipId());
|
||||
|
||||
if (actorMembershipId != command.assigneeMembershipId()
|
||||
&& !isCurrentLeader(project.id(), actorMembershipId)) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
validateDueDate(project, command.dueDate());
|
||||
|
||||
long taskId = jdbc.sql("""
|
||||
insert into tasks
|
||||
(project_id, assignee_membership_id, title, description, due_date,
|
||||
created_by_membership_id, assigned_by_membership_id)
|
||||
values (:projectId, :assigneeId, :title, :description, :dueDate,
|
||||
:actorMembershipId, :actorMembershipId)
|
||||
returning id
|
||||
""")
|
||||
.param("projectId", project.id())
|
||||
.param("assigneeId", command.assigneeMembershipId())
|
||||
.param("title", title)
|
||||
.param("description", trimToNull(command.description()))
|
||||
.param("dueDate", command.dueDate())
|
||||
.param("actorMembershipId", actorMembershipId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
return task(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
TaskView task = jdbc.sql("""
|
||||
select t.id, t.project_id, t.assignee_membership_id, t.title, t.description,
|
||||
t.status, t.due_date, t.created_by_membership_id,
|
||||
t.assigned_by_membership_id, t.assigned_at, t.created_at
|
||||
from tasks t
|
||||
join projects p on p.id = t.project_id
|
||||
join project_memberships m on m.id = t.assignee_membership_id
|
||||
and m.project_id = t.project_id
|
||||
where t.id = :taskId
|
||||
and t.project_id = :projectId
|
||||
and t.deleted_at is null
|
||||
and p.status = 'ACTIVE'
|
||||
and m.intern_user_id = :actorId
|
||||
and m.left_at is null
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actor.id())
|
||||
.query(TaskService::mapTask)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
if (!task.status().canTransitionTo(target)) {
|
||||
throw new TaskValidationException("Task status transition is not allowed");
|
||||
}
|
||||
jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId")
|
||||
.param("status", target.name())
|
||||
.param("taskId", taskId)
|
||||
.update();
|
||||
return task(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
|
||||
String normalizedBody = requireCommentBody(body);
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
ProjectAccess project = requireProjectAccess(projectId);
|
||||
if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
|
||||
long commentId = jdbc.sql("""
|
||||
insert into task_comments (task_id, author_user_id, body)
|
||||
values (:taskId, :actorId, :body)
|
||||
returning id
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("actorId", actor.id())
|
||||
.param("body", normalizedBody)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
return comment(commentId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskListView list(String actorEmail, long projectId) {
|
||||
requireViewAccess(actorEmail, projectId);
|
||||
List<TaskView> tasks = jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where project_id = :projectId and deleted_at is null
|
||||
order by id
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(TaskService::mapTask)
|
||||
.list();
|
||||
return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskDetails details(String actorEmail, long projectId, long taskId) {
|
||||
requireViewAccess(actorEmail, projectId);
|
||||
TaskView task = jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where id = :taskId and project_id = :projectId and deleted_at is null
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.query(TaskService::mapTask)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
List<TaskCommentView> comments = jdbc.sql("""
|
||||
select id, task_id, author_user_id, body, created_at
|
||||
from task_comments
|
||||
where task_id = :taskId
|
||||
order by created_at, id
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.query(TaskService::mapComment)
|
||||
.list();
|
||||
return new TaskDetails(task, comments);
|
||||
}
|
||||
|
||||
private Actor requireActiveActor(String email) {
|
||||
Actor actor = requireReadableActor(email);
|
||||
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
private Actor requireReadableActor(String email) {
|
||||
return jdbc.sql("""
|
||||
select u.id, u.global_role, i.internship_status
|
||||
from app_users u
|
||||
left join intern_profiles i on i.user_id = u.id
|
||||
where lower(btrim(u.email)) = lower(btrim(:email))
|
||||
and u.account_status = 'ACTIVE'
|
||||
""")
|
||||
.param("email", email)
|
||||
.query((rs, rowNum) -> new Actor(
|
||||
rs.getLong("id"),
|
||||
rs.getString("global_role"),
|
||||
rs.getString("internship_status")))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private Project requireOpenProject(long projectId) {
|
||||
return jdbc.sql("""
|
||||
select id, status, start_date, end_date
|
||||
from projects
|
||||
where id = :projectId and status in ('PLANNED', 'ACTIVE')
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query((rs, rowNum) -> new Project(
|
||||
rs.getLong("id"),
|
||||
rs.getString("status"),
|
||||
rs.getObject("start_date", LocalDate.class),
|
||||
rs.getObject("end_date", LocalDate.class)))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private long requireActorMembership(long projectId, long userId) {
|
||||
return jdbc.sql("""
|
||||
select m.id
|
||||
from project_memberships m
|
||||
join app_users u on u.id = m.intern_user_id
|
||||
join intern_profiles i on i.user_id = m.intern_user_id
|
||||
where m.project_id = :projectId
|
||||
and m.intern_user_id = :userId
|
||||
and m.left_at is null
|
||||
and u.account_status = 'ACTIVE'
|
||||
and i.internship_status = 'ACTIVE'
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("userId", userId)
|
||||
.query(Long.class)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private void requireAssigneeMembership(long projectId, long membershipId) {
|
||||
boolean exists = jdbc.sql("""
|
||||
select exists (
|
||||
select 1
|
||||
from project_memberships m
|
||||
join app_users u on u.id = m.intern_user_id
|
||||
join intern_profiles i on i.user_id = m.intern_user_id
|
||||
where m.id = :membershipId
|
||||
and m.project_id = :projectId
|
||||
and m.left_at is null
|
||||
and u.account_status = 'ACTIVE'
|
||||
and i.internship_status = 'ACTIVE'
|
||||
)
|
||||
""")
|
||||
.param("membershipId", membershipId)
|
||||
.param("projectId", projectId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (!exists) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentLeader(long projectId, long membershipId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_leadership_terms
|
||||
where project_id = :projectId
|
||||
and membership_id = :membershipId
|
||||
and ended_at is null
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("membershipId", membershipId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private void requireViewAccess(String actorEmail, long projectId) {
|
||||
Actor actor = requireReadableActor(actorEmail);
|
||||
ProjectAccess project = requireProjectAccess(projectId);
|
||||
if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) {
|
||||
return;
|
||||
}
|
||||
boolean member = jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_memberships
|
||||
where project_id = :projectId
|
||||
and intern_user_id = :actorId
|
||||
and (:completed or left_at is null)
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actor.id())
|
||||
.param("completed", "COMPLETED".equals(project.status()))
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (!member) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectAccess requireProjectAccess(long projectId) {
|
||||
return jdbc.sql("select status, mentor_user_id from projects where id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.query((rs, rowNum) -> new ProjectAccess(
|
||||
rs.getString("status"), rs.getLong("mentor_user_id")))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private boolean hasActiveMembership(long projectId, long actorId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_memberships
|
||||
where project_id = :projectId
|
||||
and intern_user_id = :actorId
|
||||
and left_at is null
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actorId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private boolean taskExists(long projectId, long taskId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from tasks
|
||||
where id = :taskId and project_id = :projectId and deleted_at is null
|
||||
)
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private void validateDueDate(Project 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");
|
||||
}
|
||||
boolean dayOff = jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from global_calendar_events
|
||||
where calendar_date = :dueDate and is_day_off = true
|
||||
)
|
||||
""")
|
||||
.param("dueDate", dueDate)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (dayOff) {
|
||||
throw new TaskValidationException("Due date cannot be a current global day off");
|
||||
}
|
||||
}
|
||||
|
||||
private TaskView task(long taskId) {
|
||||
return jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where id = :taskId
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.query(TaskService::mapTask)
|
||||
.single();
|
||||
}
|
||||
|
||||
private TaskCommentView comment(long commentId) {
|
||||
return jdbc.sql("""
|
||||
select id, task_id, author_user_id, body, created_at
|
||||
from task_comments
|
||||
where id = :commentId
|
||||
""")
|
||||
.param("commentId", commentId)
|
||||
.query(TaskService::mapComment)
|
||||
.single();
|
||||
}
|
||||
|
||||
private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TaskView(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("project_id"),
|
||||
rs.getLong("assignee_membership_id"),
|
||||
rs.getString("title"),
|
||||
rs.getString("description"),
|
||||
TaskStatus.valueOf(rs.getString("status")),
|
||||
rs.getObject("due_date", LocalDate.class),
|
||||
rs.getLong("created_by_membership_id"),
|
||||
rs.getLong("assigned_by_membership_id"),
|
||||
rs.getTimestamp("assigned_at").toInstant(),
|
||||
rs.getTimestamp("created_at").toInstant());
|
||||
}
|
||||
|
||||
private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TaskCommentView(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("task_id"),
|
||||
rs.getLong("author_user_id"),
|
||||
rs.getString("body"),
|
||||
rs.getTimestamp("created_at").toInstant());
|
||||
}
|
||||
|
||||
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 trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String requireCommentBody(String body) {
|
||||
String trimmed = trimToNull(body);
|
||||
if (trimmed == null) {
|
||||
throw new TaskValidationException("Comment body is required");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private record Actor(long id, String role, String internshipStatus) {}
|
||||
|
||||
private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {}
|
||||
|
||||
private record ProjectAccess(String status, long mentorUserId) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
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,17 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TaskView(
|
||||
long id,
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
LocalDate dueDate,
|
||||
long creatorMembershipId,
|
||||
long assignerMembershipId,
|
||||
Instant assignedAt,
|
||||
Instant createdAt) {}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.lab.labtimesheet.tasks.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.tasks.TaskCommentView;
|
||||
import com.lab.labtimesheet.tasks.TaskDetails;
|
||||
import com.lab.labtimesheet.tasks.TaskListView;
|
||||
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.tasks.TaskService;
|
||||
import com.lab.labtimesheet.tasks.TaskStatus;
|
||||
import com.lab.labtimesheet.tasks.TaskValidationException;
|
||||
import com.lab.labtimesheet.tasks.TaskView;
|
||||
import java.time.LocalDate;
|
||||
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 TaskService taskService;
|
||||
|
||||
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);
|
||||
|
||||
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.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");
|
||||
|
||||
long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
|
||||
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);
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
private void completeProject() {
|
||||
jdbc.sql("""
|
||||
update projects
|
||||
set status = 'COMPLETED', activated_at = current_timestamp,
|
||||
completed_at = current_timestamp
|
||||
where id = :id
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user