feat(tasks): persist iteration 1 task workflow

This commit is contained in:
sechmachine
2026-08-14 23:41:42 +07:00
parent d2d3cc57c3
commit 597ebf1b49
11 changed files with 937 additions and 1 deletions
@@ -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();
}
}