feat(tasks): persist iteration 1 task workflow
This commit is contained in:
@@ -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