From 17a3c5dc70473e9f1010f8a197ad6916dae9eaea Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:31:50 +0700 Subject: [PATCH 01/12] feat(tasks): define status graph and progress --- docs/tests/unit/task-status-progress.md | 75 +++++++++++++++++++ .../lab/labtimesheet/tasks/TaskProgress.java | 40 ++++++++++ .../lab/labtimesheet/tasks/TaskStatus.java | 17 +++++ .../tasks/TaskDomainRulesTest.java | 61 +++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 docs/tests/unit/task-status-progress.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java create mode 100644 src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md new file mode 100644 index 0000000..6da6cc6 --- /dev/null +++ b/docs/tests/unit/task-status-progress.md @@ -0,0 +1,75 @@ +# Test Evidence: Fixed Task status graph and initial Project progress + +- **Test type:** Unit +- **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` +- **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The Task status graph accepts exactly the seven specified directed edges. Initial Project progress counts each current Task status and represents a Project without current Tasks as no percentage rather than zero percent. + +## Test method + +One parameterized test checks all 16 source/target status pairs against a hand-written allowed-edge table. Two focused tests check empty progress and a four-Task example with two `DONE` Tasks. + +## Hand-derived expected result + +Allowed edges are `TODO` to `IN_PROGRESS` or `BLOCKED`; `IN_PROGRESS` to `DONE` or `BLOCKED`; `BLOCKED` to `TODO` or `IN_PROGRESS`; and `DONE` to `IN_PROGRESS`. All other pairs are forbidden. Zero Tasks has no percentage. Two `DONE` among four Tasks is 50%, with counts 1 `TODO`, 1 `IN_PROGRESS`, 0 `BLOCKED`, and 2 `DONE`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[ERROR] COMPILATION ERROR : +TaskDomainRulesTest.java:[16,30] cannot find symbol + symbol: class TaskStatus +[INFO] BUILD FAILURE +``` + +The test could not compile because the required Task status and progress domain types did not exist. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit evidence does not prove current-assignee authorization, Project lifecycle enforcement, PostgreSQL persistence/query filtering, non-deleted selection, HTTP authorization, or rendered `N/A`. Those require the platform/Project foundation and PostgreSQL/web tests. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java new file mode 100644 index 0000000..632d8e7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java @@ -0,0 +1,40 @@ +package com.lab.labtimesheet.tasks; + +import java.util.Collection; +import java.util.OptionalDouble; + +public record TaskProgress(int todo, int inProgress, int blocked, int done) { + + public static TaskProgress from(Collection 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()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java new file mode 100644 index 0000000..5aff97a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet.tasks; + +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; + }; + } +} diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java new file mode 100644 index 0000000..af42eaf --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java @@ -0,0 +1,61 @@ +package com.lab.labtimesheet.tasks; + +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> 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 allStatusTransitions() { + return Stream.of(TaskStatus.values()) + .flatMap(current -> Stream.of(TaskStatus.values()) + .map(target -> Arguments.of( + current, + target, + ALLOWED_TRANSITIONS.get(current).contains(target)))); + } +} From 597ebf1b49ef0a48fc8b75c6f8f9c16632747666 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:41:42 +0700 Subject: [PATCH 02/12] feat(tasks): persist iteration 1 task workflow --- docs/tests/integration/task-workflow.md | 88 ++++ docs/tests/unit/task-status-progress.md | 2 +- .../labtimesheet/tasks/CreateTaskCommand.java | 10 + .../labtimesheet/tasks/TaskCommentView.java | 5 + .../lab/labtimesheet/tasks/TaskDetails.java | 10 + .../lab/labtimesheet/tasks/TaskListView.java | 10 + .../tasks/TaskNotFoundException.java | 12 + .../lab/labtimesheet/tasks/TaskService.java | 412 ++++++++++++++++++ .../tasks/TaskValidationException.java | 12 + .../com/lab/labtimesheet/tasks/TaskView.java | 17 + .../TaskCreationIntegrationTest.java | 360 +++++++++++++++ 11 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 docs/tests/integration/task-workflow.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskListView.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskService.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskView.java create mode 100644 src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md new file mode 100644 index 0000000..0eb7806 --- /dev/null +++ b/docs/tests/integration/task-workflow.md @@ -0,0 +1,88 @@ +# Test Evidence: Iteration 1 Task persistence and authorization + +- **Test type:** Integration +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-007`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.TaskCreationIntegrationTest` +- **Implementation commit:** `pending` + +## Protected behavior + +PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render empty progress as absent, and deny guessed/cross-Project identifiers without writes. + +## Test method + +Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. + +## Hand-derived expected result + +A self-Task stores one membership in creator, assigner, and assignee fields. A Leader-created Task retains the Leader membership as creator/assigner and the selected member as assignee. Project start/end due dates are valid; dates before, after, or on a current global day off are invalid. Only an active Project's current assignee can traverse an allowed status edge. Authorized member/Mentor comments append two rows. Four current Tasks with one in each status produce 25% and four unit counts; a deleted fifth Task is absent; zero Tasks has no percentage. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskCreationIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] TaskCreationIntegrationTest.java:[6,34] cannot find symbol + symbol: class CreateTaskCommand +[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol + symbol: class TaskService +[INFO] BUILD FAILURE +``` + +After creation reached GREEN, the next cohesive workflow increment was separately observed RED: + +```text +[ERROR] TaskCreationIntegrationTest.java:[7,34] cannot find symbol + symbol: class TaskCommentView +[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol + symbol: class TaskDetails +[ERROR] TaskCreationIntegrationTest.java:[9,34] cannot find symbol + symbol: class TaskListView +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskCreationIntegrationTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This evidence does not prove browser behavior, shared-shell integration, notification delivery, Iteration 2 work logs/reassignment/edit/deletion, or Iteration 3 concurrency/index plans. The status edge matrix is separately protected by unit evidence. HTTP form, CSRF, template, and direct-route behavior require the companion web evidence. diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md index 6da6cc6..9e31804 100644 --- a/docs/tests/unit/task-status-progress.md +++ b/docs/tests/unit/task-status-progress.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` - **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` - **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `17a3c5d` ## Protected behavior diff --git a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java new file mode 100644 index 0000000..d10fc00 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java @@ -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) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java new file mode 100644 index 0000000..25be01e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java @@ -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) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java new file mode 100644 index 0000000..2cacfe7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.tasks; + +import java.util.List; + +public record TaskDetails(TaskView task, List comments) { + + public TaskDetails { + comments = List.copyOf(comments); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java new file mode 100644 index 0000000..1acfed2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.tasks; + +import java.util.List; + +public record TaskListView(List tasks, TaskProgress progress) { + + public TaskListView { + tasks = List.copyOf(tasks); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java new file mode 100644 index 0000000..dde94dd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java @@ -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"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java new file mode 100644 index 0000000..2765ba8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java @@ -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 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 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) {} +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java new file mode 100644 index 0000000..c32968b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java @@ -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); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java b/src/main/java/com/lab/labtimesheet/tasks/TaskView.java new file mode 100644 index 0000000..4b0fb48 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskView.java @@ -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) {} diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java new file mode 100644 index 0000000..57f9634 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java @@ -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(); + } +} From 1ed23f4de9abdfb06108522f9d6a3257a60eca10 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:48:16 +0700 Subject: [PATCH 03/12] feat(tasks): add task pages and request boundaries --- docs/tests/integration/task-workflow.md | 6 +- docs/tests/web/task-pages.md | 78 +++++++++++ .../tasks/TaskAssigneeChoice.java | 3 + .../labtimesheet/tasks/TaskController.java | 108 ++++++++++++++ .../labtimesheet/tasks/TaskCreateForm.java | 13 ++ .../lab/labtimesheet/tasks/TaskService.java | 26 ++++ .../resources/templates/tasks/detail.html | 36 +++++ src/main/resources/templates/tasks/form.html | 37 +++++ src/main/resources/templates/tasks/list.html | 32 +++++ .../TaskCreationIntegrationTest.java | 11 ++ .../tasks/TaskControllerTest.java | 132 ++++++++++++++++++ 11 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 docs/tests/web/task-pages.md create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskController.java create mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java create mode 100644 src/main/resources/templates/tasks/detail.html create mode 100644 src/main/resources/templates/tasks/form.html create mode 100644 src/main/resources/templates/tasks/list.html create mode 100644 src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 0eb7806..0ac76ea 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -12,7 +12,7 @@ PostgreSQL-backed Task operations preserve generic same-Project membership actor ## Test method -Eight transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. +Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. ## Hand-derived expected result @@ -65,7 +65,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md new file mode 100644 index 0000000..d7db642 --- /dev/null +++ b/docs/tests/web/task-pages.md @@ -0,0 +1,78 @@ +# Test Evidence: Task pages and server-side request boundaries + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` +- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project. + +## Test method + +Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary. + +## Hand-derived expected result + +Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +**Observed result** + +```text +[ERROR] TaskControllerTest.java:[28,13] cannot find symbol + symbol: class TaskController +[INFO] BUILD FAILURE +``` + +The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskControllerTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +The suite ran with approved escalation for OrbStack and Mockito self-attach. + +## External-test boundaries + +This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest`. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java new file mode 100644 index 0000000..6e09bb1 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.tasks; + +public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java new file mode 100644 index 0000000..0188b1a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskController.java @@ -0,0 +1,108 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.Valid; +import java.util.Locale; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class TaskController { + + private final TaskService taskService; + + public TaskController(TaskService taskService) { + this.taskService = taskService; + } + + @GetMapping("/projects/{projectId}/tasks") + String list(Authentication authentication, @PathVariable long projectId, Model model) { + TaskListView taskList = taskService.list(authentication.getName(), projectId); + model.addAttribute("projectId", projectId); + model.addAttribute("taskList", taskList); + model.addAttribute("progressLabel", progressLabel(taskList.progress())); + return "tasks/list"; + } + + @GetMapping("/projects/{projectId}/tasks/new") + String createForm(Authentication authentication, @PathVariable long projectId, Model model) { + model.addAttribute("taskForm", new TaskCreateForm("", "", null, null)); + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + + @PostMapping("/projects/{projectId}/tasks") + String create( + Authentication authentication, + @PathVariable long projectId, + @Valid @ModelAttribute("taskForm") TaskCreateForm form, + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + populateForm(authentication.getName(), projectId, model); + return "tasks/form"; + } + TaskView task = taskService.create( + authentication.getName(), + new CreateTaskCommand( + projectId, + form.assigneeMembershipId(), + form.title(), + form.description(), + form.dueDate())); + return "redirect:/projects/%d/tasks/%d".formatted(projectId, task.id()); + } + + @GetMapping("/projects/{projectId}/tasks/{taskId}") + String details( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("details", taskService.details(authentication.getName(), projectId, taskId)); + model.addAttribute("statuses", TaskStatus.values()); + return "tasks/detail"; + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/status") + String changeStatus( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam TaskStatus status) { + taskService.changeStatus(authentication.getName(), projectId, taskId, status); + return detailsRedirect(projectId, taskId); + } + + @PostMapping("/projects/{projectId}/tasks/{taskId}/comments") + String addComment( + Authentication authentication, + @PathVariable long projectId, + @PathVariable long taskId, + @RequestParam String body) { + taskService.addComment(authentication.getName(), projectId, taskId, body); + return detailsRedirect(projectId, taskId); + } + + private void populateForm(String actorEmail, long projectId, Model model) { + model.addAttribute("projectId", projectId); + model.addAttribute("assignees", taskService.assignmentChoices(actorEmail, projectId)); + } + + private static String detailsRedirect(long projectId, long taskId) { + return "redirect:/projects/%d/tasks/%d".formatted(projectId, taskId); + } + + private static String progressLabel(TaskProgress progress) { + return progress.completionPercentage().isEmpty() + ? "N/A" + : String.format(Locale.ROOT, "%.1f%%", progress.completionPercentage().getAsDouble()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java new file mode 100644 index 0000000..fb0a92d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.tasks; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +public record TaskCreateForm( + @NotBlank @Size(max = 200) String title, + String description, + @NotNull Long assigneeMembershipId, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDate) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java index 2765ba8..03fbe68 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java @@ -153,6 +153,32 @@ public class TaskService { return new TaskDetails(task, comments); } + @Transactional(readOnly = true) + public List assignmentChoices(String actorEmail, long projectId) { + Actor actor = requireActiveActor(actorEmail); + requireOpenProject(projectId); + long actorMembershipId = requireActorMembership(projectId, actor.id()); + boolean leader = isCurrentLeader(projectId, actorMembershipId); + return jdbc.sql(""" + select m.id, u.display_name + from project_memberships m + join app_users u on u.id = m.intern_user_id + join intern_profiles i on i.user_id = m.intern_user_id + where m.project_id = :projectId + and m.left_at is null + and u.account_status = 'ACTIVE' + and i.internship_status = 'ACTIVE' + and (:leader or m.id = :actorMembershipId) + order by m.id + """) + .param("projectId", projectId) + .param("leader", leader) + .param("actorMembershipId", actorMembershipId) + .query((rs, rowNum) -> new TaskAssigneeChoice( + rs.getLong("id"), rs.getString("display_name"))) + .list(); + } + private Actor requireActiveActor(String email) { Actor actor = requireReadableActor(email); if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) { diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html new file mode 100644 index 0000000..1e9ba7d --- /dev/null +++ b/src/main/resources/templates/tasks/detail.html @@ -0,0 +1,36 @@ + + + + + + Task + + +
+

Task

+

No description

+

Status: TODO

+

Due date:

+ +
+ + + +
+ +
+

Comments

+
    +
  1. Comment
  2. +
+
+ + + +
+
+
+ + diff --git a/src/main/resources/templates/tasks/form.html b/src/main/resources/templates/tasks/form.html new file mode 100644 index 0000000..c604e00 --- /dev/null +++ b/src/main/resources/templates/tasks/form.html @@ -0,0 +1,37 @@ + + + + + + Create Task + + +
+

Create Task

+
+
+ + +

Title error

+
+
+ + +
+
+ + +

Assignee error

+
+
+ + +
+ +
+
+ + diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html new file mode 100644 index 0000000..7b830e0 --- /dev/null +++ b/src/main/resources/templates/tasks/list.html @@ -0,0 +1,32 @@ + + + + + + Project tasks + + +
+

Project tasks

+

Progress: N/A

+
+
TODO
0
+
IN_PROGRESS
0
+
BLOCKED
0
+
DONE
0
+
+

Create Task

+ + + + + + + + + + +
Current non-deleted Tasks
TitleStatusDue date
TaskTODO
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java index 57f9634..ff52682 100644 --- a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java @@ -7,6 +7,7 @@ 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.TaskAssigneeChoice; import com.lab.labtimesheet.tasks.TaskNotFoundException; import com.lab.labtimesheet.tasks.TaskService; import com.lab.labtimesheet.tasks.TaskStatus; @@ -233,6 +234,16 @@ class TaskCreationIntegrationTest { .isInstanceOf(TaskNotFoundException.class); } + @Test + void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() { + assertThat(taskService.assignmentChoices("member@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(memberMembershipId); + assertThat(taskService.assignmentChoices("leader@example.test", projectId)) + .extracting(TaskAssigneeChoice::membershipId) + .containsExactly(leaderMembershipId, memberMembershipId); + } + private long insertUser(String email, String role) { return jdbc.sql(""" insert into app_users diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java new file mode 100644 index 0000000..6f69df5 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java @@ -0,0 +1,132 @@ +package com.lab.labtimesheet.tasks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TaskController.class) +class TaskControllerTest { + + private static final String ACTOR_EMAIL = "member@example.test"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TaskService taskService; + + @Test + void taskListRequiresAuthentication() throws Exception { + mockMvc.perform(get("/projects/10/tasks")) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(taskService); + } + + @Test + void emptyTaskListRendersNotApplicableProgress() throws Exception { + given(taskService.list(ACTOR_EMAIL, 10L)) + .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()))); + + mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL))) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/list")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("N/A"))); + } + + @Test + void guessedTaskIdentifierReturnsNotFoundWithoutRenderingDetails() throws Exception { + given(taskService.details(ACTOR_EMAIL, 10L, 999L)).willThrow(new TaskNotFoundException()); + + mockMvc.perform(get("/projects/10/tasks/999").with(user(ACTOR_EMAIL))) + .andExpect(status().isNotFound()); + } + + @Test + void validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask() throws Exception { + given(taskService.create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class))) + .willReturn(task(25L)); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", "Draft") + .param("description", "Notes") + .param("assigneeMembershipId", "7") + .param("dueDate", "2026-08-20")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + + ArgumentCaptor command = ArgumentCaptor.forClass(CreateTaskCommand.class); + verify(taskService).create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), command.capture()); + assertThat(command.getValue()).isEqualTo(new CreateTaskCommand( + 10L, 7L, "Draft", "Notes", LocalDate.of(2026, 8, 20))); + } + + @Test + void blankCreateFormRendersValidationErrorWithoutWriting() throws Exception { + given(taskService.assignmentChoices(ACTOR_EMAIL, 10L)) + .willReturn(List.of(new TaskAssigneeChoice(7L, "Member"))); + + mockMvc.perform(post("/projects/10/tasks") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("title", " ") + .param("assigneeMembershipId", "7")) + .andExpect(status().isOk()) + .andExpect(view().name("tasks/form")) + .andExpect(model().attributeHasFieldErrors("taskForm", "title")); + + verify(taskService, org.mockito.Mockito.never()) + .create(org.mockito.ArgumentMatchers.eq(ACTOR_EMAIL), any(CreateTaskCommand.class)); + } + + @Test + void statusAndCommentPostsUseAuthenticatedIdentityAndCsrf() throws Exception { + given(taskService.changeStatus(ACTOR_EMAIL, 10L, 25L, TaskStatus.IN_PROGRESS)) + .willReturn(task(25L)); + given(taskService.addComment(ACTOR_EMAIL, 10L, 25L, "Update")) + .willReturn(new TaskCommentView(3L, 25L, 5L, "Update", Instant.parse("2026-08-14T10:00:00Z"))); + + mockMvc.perform(post("/projects/10/tasks/25/status") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("status", "IN_PROGRESS")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + mockMvc.perform(post("/projects/10/tasks/25/comments") + .with(user(ACTOR_EMAIL)) + .with(csrf()) + .param("body", "Update")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/projects/10/tasks/25")); + } + + private static TaskView task(long id) { + Instant instant = Instant.parse("2026-08-14T10:00:00Z"); + return new TaskView( + id, 10L, 7L, "Draft", "Notes", TaskStatus.TODO, + LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); + } +} From 7acd5252058ddf9c6c9b71693a49a440e02a3500 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:27:35 +0700 Subject: [PATCH 04/12] docs(project): record feature JPA milestone evidence --- docs/tests/integration/projects-workflows.md | 75 ++++++++++++++++++++ docs/tests/unit/projects-domain.md | 10 +-- docs/tests/unit/projects-layer-structure.md | 75 ++++++++++++++++++++ docs/tests/web/projects-pages.md | 73 +++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 docs/tests/integration/projects-workflows.md create mode 100644 docs/tests/unit/projects-layer-structure.md create mode 100644 docs/tests/web/projects-pages.md diff --git a/docs/tests/integration/projects-workflows.md b/docs/tests/integration/projects-workflows.md new file mode 100644 index 0000000..dd985a1 --- /dev/null +++ b/docs/tests/integration/projects-workflows.md @@ -0,0 +1,75 @@ +# Test Evidence: Atomic Project workflows + +- **Test type:** Integration +- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007` +- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest` +- **Implementation commit:** `25a855e` + +## Protected behavior + +PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, and enforce role/membership visibility without ID disclosure. + +## Test method + +A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testcontainer and Flyway V1 schema. It calls the public Project service and verifies committed-shape rows and negative-case non-mutation with independent SQL. + +## Hand-derived expected result + +Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class CreateProjectCommand +[ERROR] cannot find symbol: class ProjectService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=ProjectServiceIntegrationTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, real concurrent transaction races, Iteration 2 invitations/removals/completion, or Task-module business rules beyond preserving stored assignment IDs. `I1-PRJ-04` remains `IN_PROGRESS`: the activation Task-assignee guard will be implemented only after the Task feature exposes its concrete query service. diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md index 18e5512..27467e4 100644 --- a/docs/tests/unit/projects-domain.md +++ b/docs/tests/unit/projects-domain.md @@ -3,8 +3,8 @@ - **Test type:** Unit - **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004` - **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009` -- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest` -- **Implementation commit:** `3483347` +- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest` +- **Implementation commit:** `25a855e` ## Protected behavior @@ -43,13 +43,13 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test ``` **Observed result** ```text -[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest +[INFO] Running com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -61,7 +61,7 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="$JAVA_HOME/bin:$PATH" -./mvnw -Dtest=ProjectTest test +./mvnw -Dtest=ProjectEntityTest test [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS diff --git a/docs/tests/unit/projects-layer-structure.md b/docs/tests/unit/projects-layer-structure.md new file mode 100644 index 0000000..e27ad70 --- /dev/null +++ b/docs/tests/unit/projects-layer-structure.md @@ -0,0 +1,75 @@ +# Test Evidence: Project layer and JPA structure + +- **Test type:** Unit +- **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010` +- **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05` +- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa` +- **Implementation commit:** `25a855e` + +## Protected behavior + +Project-owned production code follows the authoritative feature-first package layout, persists aggregate entities through Spring Data JPA, keeps JDBC operations out of Project business services, and does not shadow Account or Task persistence. + +## Test method + +Plain JUnit inspects the public Project entity, repository, and service types. It verifies their exact feature/layer packages, the entity's JPA mapping, the repository's `JpaRepository` contract, the absence of JDBC service dependencies, and the absence of foreign-table Account/Task shadow entities. + +## Hand-derived expected result + +The Project aggregate is under `feature.project.model.entity`, persistence under `feature.project.repository`, business logic under `feature.project.service`, the service has zero JDBC collaborators, and Account/Task persistence remains owned by those features. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectUserRepository +[ERROR] cannot find symbol: class ProjectInternProfileRepository +[ERROR] cannot find symbol: class ProjectTaskRepository +[INFO] BUILD FAILURE +``` + +The RED was observed after removing Project-owned shadow mappings of Account and Task tables. It proves the service still required cross-feature dependencies and could not be made green by retaining forbidden repositories. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectPersistenceStructureTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=LayerStructureTest,ProjectPersistenceStructureTest,ProjectEntityTest test + +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md new file mode 100644 index 0000000..df25b1f --- /dev/null +++ b/docs/tests/web/projects-pages.md @@ -0,0 +1,73 @@ +# Test Evidence: Authorized Project pages + +- **Test type:** Web +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05` +- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` +- **Implementation commit:** `25a855e` + +## Protected behavior + +Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; state changes require CSRF. + +## Test method + +MockMvc exercises the real controller, binding, Bean Validation, exception mapping, view selection, redirect, Spring Security authentication, and CSRF filter. Only application/query services are mocked. + +## Hand-derived expected result + +An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. POST without CSRF returns 403. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class ProjectController +[ERROR] cannot find symbol: class ProjectPageService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectControllerTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available. From a9ee99a6fef0a12483ee0d6ad3390fa1e8708d35 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:30:22 +0700 Subject: [PATCH 05/12] fix(project): hide mentor-only controls --- .../project/controller/ProjectController.java | 6 ++++- .../project/model/dto/ProjectDetail.java | 3 ++- .../project/service/ProjectQueryService.java | 3 ++- .../templates/projects/leadership.html | 2 +- .../resources/templates/projects/members.html | 2 +- .../controller/ProjectControllerTest.java | 24 ++++++++++++++++--- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 16c4758..79cfd10 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -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; @@ -34,7 +35,10 @@ public class ProjectController { } @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"; } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java index 4af0e54..72a13a0 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectDetail.java @@ -10,5 +10,6 @@ public record ProjectDetail( LocalDate startDate, LocalDate endDate, String mentorName, - String leaderName) { + String leaderName, + boolean canManage) { } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index eadff93..593bfc9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -64,7 +64,8 @@ public class ProjectQueryService { project.startDate(), project.endDate(), displayName(project.mentorUserId()), - displayName(project.currentLeader().internUserId())); + displayName(project.currentLeader().internUserId()), + project.mentorUserId() == actorUserId); } @Transactional(readOnly = true) diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html index 378a69c..abe41e9 100644 --- a/src/main/resources/templates/projects/leadership.html +++ b/src/main/resources/templates/projects/leadership.html @@ -7,7 +7,7 @@
Leadership history
LeaderStartedEnded
-
+
diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index e0238eb..8ee0e9f 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -7,7 +7,7 @@
Membership history
InternJoinedLeftRole
-
+
diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index ed3ef9f..4a13cec 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -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; @@ -79,16 +82,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 From 2f2573159c34833b2e70a6c6a07f433da308af7b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:38:03 +0700 Subject: [PATCH 06/12] fix(project): hide unavailable create action --- docs/tests/web/projects-pages.md | 14 ++++++++++++- .../project/controller/ProjectController.java | 4 +++- .../resources/templates/projects/list.html | 2 +- .../controller/ProjectControllerTest.java | 20 +++++++++++++++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/tests/web/projects-pages.md b/docs/tests/web/projects-pages.md index df25b1f..905adb2 100644 --- a/docs/tests/web/projects-pages.md +++ b/docs/tests/web/projects-pages.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001` - **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05` - **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest` -- **Implementation commit:** `25a855e` +- **Implementation commits:** `25a855e`, `a9ee99a` ## Protected behavior @@ -68,6 +68,18 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock [INFO] BUILD SUCCESS ``` +## Mentor-only control regression + +**RED:** the focused MockMvc run reported two expected failures: `GET /projects/new` returned `200` for an Intern instead of non-disclosing `404`, and the member page rendered the `Add member` form for a non-owner. + +**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after the controller/DTO/template correction passed 7 tests with zero failures, errors, or skips. + +## Role-aware Project-list action regression + +**RED:** the focused MockMvc run reported two expected failures after adding the list-action regression: the controller still resolved only a user ID, so the Mentor fixture was queried as user `0`, and an Intern-facing Project list rendered the `Create Project` link. + +**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips. + ## External-test boundaries This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index 79cfd10..113b50a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -30,7 +30,9 @@ 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"; } diff --git a/src/main/resources/templates/projects/list.html b/src/main/resources/templates/projects/list.html index 9379ac8..67ec7ac 100644 --- a/src/main/resources/templates/projects/list.html +++ b/src/main/resources/templates/projects/list.html @@ -4,7 +4,7 @@

Projects

- Create Project + Create Project

No authorized Projects.

diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 4a13cec..2a26916 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -44,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", @@ -55,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 { From 19a3518f74c283df3cc742264426b995a8bfa359 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:03:51 +0700 Subject: [PATCH 07/12] feat(project): lock task mutation context --- .../project/service/ProjectQueryService.java | 15 ++++- .../project/service/ProjectService.java | 9 +++ .../ProjectServiceIntegrationTest.java | 7 ++- .../ProjectTaskMutationContextTest.java | 59 +++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 593bfc9..1867eda 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -97,7 +97,12 @@ 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); var activeMembers = project.memberships().stream() .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) .map(membership -> new ProjectTaskMemberView( @@ -142,15 +147,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 visibleProjects(AccountIdentity actor, long actorUserId) { diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 31c994f..946b7c3 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -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); } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index 8db027a..a353e45 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -144,11 +144,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,7 +172,7 @@ 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()); diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java new file mode 100644 index 0000000..8f31971 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java @@ -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); + } +} From c3f92d4e310d625d95d374e974c478444331f6d7 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:04:29 +0700 Subject: [PATCH 08/12] docs(project): record locked task context evidence --- .../unit/project-task-mutation-context.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/tests/unit/project-task-mutation-context.md diff --git a/docs/tests/unit/project-task-mutation-context.md b/docs/tests/unit/project-task-mutation-context.md new file mode 100644 index 0000000..b71c70d --- /dev/null +++ b/docs/tests/unit/project-task-mutation-context.md @@ -0,0 +1,75 @@ +# Test Evidence: Locked Project context for Task mutations + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-001`, `AUTH-011`, `PRJ-012` +- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-010`, `AC-PRJ-006` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest#loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext` +- **Implementation commit:** `19a3518` + +## Protected behavior + +Task mutations obtain their Project authorization and current lifecycle, Leader, owning-Mentor, and active-member facts from a DTO-only Project service boundary after the Project row has been locked for update. Missing and unauthorized Projects retain the same non-disclosing denial behavior. + +## Test method + +The isolated service test invokes `ProjectService.taskMutationContext(actorUserId, projectId)`, verifies that `ProjectRepository.findLockedById` is used and the ordinary `findById` path is not used, and verifies that only the locked entity is passed to the existing Project-owned authorization and DTO mapper. The PostgreSQL integration test additionally exercises the public API with authorized, unauthorized, current-member, and former-member data. + +## Hand-derived expected result + +Exactly one pessimistic Project lookup occurs before context evaluation. The returned `ProjectTaskContext` exposes scalar/DTO facts only; no Project repository or entity crosses the feature boundary. When called from Task's active transaction, Spring's default `REQUIRED` propagation keeps the row lock in that transaction through its commit or rollback. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskMutationContextTest test +``` + +**Observed result** + +```text +[ERROR] constructor ProjectService ... cannot be applied to given types +[ERROR] incompatible types: ProjectEntity cannot be converted to long +[INFO] BUILD FAILURE +``` + +The test failed to compile because Project had no mutation-context API and its context mapper accepted only an unlocked Project ID lookup. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectTaskMutationContextTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest='Project*Test' test + +[INFO] Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +The unit test proves the locked repository path and DTO-only handoff, while the integration coverage proves current Project authorization/member mapping against PostgreSQL 18.4. It does not orchestrate two concurrent database transactions; the lock-retention guarantee relies on the public method's `@Transactional` default `REQUIRED` propagation and the Task caller retaining its outer transaction. From 3d954dd512761546c4213762cc696fe0ab59022d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:18:19 +0700 Subject: [PATCH 09/12] fix(project): preserve completed task history --- .../project/service/ProjectQueryService.java | 18 ++++++++- .../ProjectServiceIntegrationTest.java | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 1867eda..3a3cf45 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -71,7 +71,9 @@ public class ProjectQueryService { @Transactional(readOnly = true) public List 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(), @@ -79,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(); } @@ -103,6 +107,16 @@ public class ProjectQueryService { 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( diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index a353e45..d98b3f7 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -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; @@ -178,6 +179,42 @@ class ProjectServiceIntegrationTest { 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, From e1aa8eb062e32a46b1b6e7afcfc99e446ca16711 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:19:00 +0700 Subject: [PATCH 10/12] docs(project): record completed history evidence --- .../integration/projects-completed-history.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/tests/integration/projects-completed-history.md diff --git a/docs/tests/integration/projects-completed-history.md b/docs/tests/integration/projects-completed-history.md new file mode 100644 index 0000000..8586b76 --- /dev/null +++ b/docs/tests/integration/projects-completed-history.md @@ -0,0 +1,77 @@ +# Test Evidence: Completed Project member and Task history + +- **Test type:** Integration +- **Requirement IDs:** `AUTH-006`, `PRJ-014` +- **Scenario IDs:** `AC-AUTH-007` +- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader` +- **Implementation commit:** `3d954dd` + +## Protected behavior + +After Project completion closes every membership and leadership interval, a historical member can still retrieve read-only Task context and member history. The Task context reports no current Leader and no active members, and every historical member row reports `currentLeader=false`. + +## Test method + +The Spring Boot integration test creates a Project and second member through public Project services, then uses direct SQL only as a fixture to reproduce the Iteration 2 completion result: all leadership and membership intervals are closed and the Project is marked `COMPLETED`. After clearing the persistence context, it calls the public Project query APIs as the former member and checks the DTOs. + +## Hand-derived expected result + +A completed Project cannot have a current Leader or active member. Therefore `ProjectTaskContext.currentLeaderMembershipId` is `null`, `activeMembers` is empty, both membership-history rows remain visible, both have leave timestamps, and neither is marked as current Leader. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test +``` + +**Observed result** + +```text +[ERROR] ProjectRuleViolationException: Project has no current Leader + at com.lab.labtimesheet.feature.project.model.entity.ProjectEntity.currentLeader(ProjectEntity.java:232) + at com.lab.labtimesheet.feature.project.service.ProjectQueryService.taskContext(ProjectQueryService.java:113) +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest='Project*Test' test + +[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This regression proves completed-state DTO behavior against PostgreSQL 18.4. It does not implement or test the future Project-completion mutation itself, browser rendering, or Task-owned authorization and presentation; direct SQL is confined to constructing the completed aggregate fixture. From 511ee81a91a79a61cc6afb00097e1b38577c1968 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:22:49 +0700 Subject: [PATCH 11/12] feat(task): adopt JPA feature boundaries --- docs/tests/integration/task-workflow.md | 27 +- docs/tests/unit/task-dashboard-query.md | 74 +++ docs/tests/unit/task-mutation-boundary.md | 77 +++ docs/tests/unit/task-persistence-structure.md | 88 ++++ .../unit/task-project-activation-query.md | 74 +++ docs/tests/unit/task-status-progress.md | 4 +- docs/tests/web/task-pages.md | 14 +- .../task/controller}/TaskController.java | 9 +- .../exception}/TaskNotFoundException.java | 2 +- .../exception}/TaskValidationException.java | 2 +- .../task/model}/TaskProgress.java | 2 +- .../task/model}/TaskStatus.java | 2 +- .../task/model/dto}/CreateTaskCommand.java | 2 +- .../task/model/dto}/TaskAssigneeChoice.java | 2 +- .../task/model/dto}/TaskCommentView.java | 2 +- .../task/model/dto}/TaskCreateForm.java | 2 +- .../task/model/dto/TaskDashboardView.java | 13 + .../feature/task/model/dto/TaskDetails.java | 14 + .../task/model/dto}/TaskListView.java | 5 +- .../task/model/dto/TaskPriorityView.java | 10 + .../task/model/dto}/TaskView.java | 4 +- .../feature/task/model/entity/Task.java | 145 ++++++ .../task/model/entity/TaskComment.java | 59 +++ .../repository/TaskCommentRepository.java | 10 + .../task/repository/TaskRepository.java | 56 +++ .../task/service/TaskDashboardService.java | 86 ++++ .../task/service/TaskQueryService.java | 24 + .../feature/task/service/TaskService.java | 323 +++++++++++++ .../lab/labtimesheet/tasks/TaskDetails.java | 10 - .../lab/labtimesheet/tasks/TaskService.java | 438 ------------------ .../resources/templates/tasks/detail.html | 5 +- src/main/resources/templates/tasks/list.html | 5 +- .../task/controller}/TaskControllerTest.java | 56 ++- .../task/model}/TaskDomainRulesTest.java | 2 +- .../TaskPersistenceStructureTest.java | 70 +++ .../service}/TaskCreationIntegrationTest.java | 214 ++++++++- .../service/TaskDashboardServiceTest.java | 108 +++++ .../service/TaskMutationBoundaryTest.java | 135 ++++++ .../task/service/TaskQueryServiceTest.java | 41 ++ 39 files changed, 1722 insertions(+), 494 deletions(-) create mode 100644 docs/tests/unit/task-dashboard-query.md create mode 100644 docs/tests/unit/task-mutation-boundary.md create mode 100644 docs/tests/unit/task-persistence-structure.md create mode 100644 docs/tests/unit/task-project-activation-query.md rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/controller}/TaskController.java (89%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/exception}/TaskNotFoundException.java (85%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/exception}/TaskValidationException.java (85%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskProgress.java (95%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskStatus.java (89%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/CreateTaskCommand.java (79%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskAssigneeChoice.java (58%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskCommentView.java (71%) rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskCreateForm.java (89%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskListView.java (52%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java rename src/main/java/com/lab/labtimesheet/{tasks => feature/task/model/dto}/TaskView.java (73%) create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java delete mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java delete mode 100644 src/main/java/com/lab/labtimesheet/tasks/TaskService.java rename src/test/java/com/lab/labtimesheet/{tasks => feature/task/controller}/TaskControllerTest.java (67%) rename src/test/java/com/lab/labtimesheet/{tasks => feature/task/model}/TaskDomainRulesTest.java (97%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java rename src/test/java/com/lab/labtimesheet/{ => feature/task/service}/TaskCreationIntegrationTest.java (64%) create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 0ac76ea..6d87de9 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -1,23 +1,25 @@ # Test Evidence: Iteration 1 Task persistence and authorization - **Test type:** Integration -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-007`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` -- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` -- **Test class/method:** `com.lab.labtimesheet.TaskCreationIntegrationTest` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest` - **Implementation commit:** `pending` ## Protected behavior -PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render empty progress as absent, and deny guessed/cross-Project identifiers without writes. +PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render assignee names and empty progress, deny guessed/cross-Project identifiers without writes, give former members read-only access only after completion, and execute the Project-activation and dashboard Task queries. ## Test method -Nine transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. +Thirteen transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations. ## Hand-derived expected result A self-Task stores one membership in creator, assigner, and assignee fields. A Leader-created Task retains the Leader membership as creator/assigner and the selected member as assignee. Project start/end due dates are valid; dates before, after, or on a current global day off are invalid. Only an active Project's current assignee can traverse an allowed status edge. Authorized member/Mentor comments append two rows. Four current Tasks with one in each status produce 25% and four unit counts; a deleted fifth Task is absent; zero Tasks has no percentage. +A former member cannot read Task data while the Project remains planned or active, but can read the completed Project history. List/detail views resolve the assignee display name from the Project service boundary, and their capability flags match current membership, assignment, role, and Project lifecycle. + ## RED **Command** @@ -51,6 +53,17 @@ After creation reached GREEN, the next cohesive workflow increment was separatel [INFO] BUILD FAILURE ``` +The review-hardening increment was also observed RED before its implementation. The former-member PostgreSQL regression reached the old list behavior instead of throwing, and the view-contract tests could not compile because `assigneeName`, `canCreate`, `canChangeStatus`, and `canComment` did not exist. + +The second review then made the completed-history fixture production-shaped by closing the current leadership term and all memberships. That focused test was observed RED because the Project read boundary still required `currentLeader()` for a completed Project: + +```text +[ERROR] TaskCreationIntegrationTest.formerMemberReadsOnlyCompletedProjectTaskHistory + » TaskNotFound Task or Project was not found +[INFO] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +[INFO] BUILD FAILURE +``` + ## GREEN **Command** @@ -65,7 +78,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock **Observed result** ```text -[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -79,7 +92,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/unit/task-dashboard-query.md b/docs/tests/unit/task-dashboard-query.md new file mode 100644 index 0000000..ec7dc52 --- /dev/null +++ b/docs/tests/unit/task-dashboard-query.md @@ -0,0 +1,74 @@ +# Test Evidence: Role-correct Task dashboard query + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-003`–`AUTH-005`, `AUTH-009`, `TSK-001`, `TSK-002`, `TSK-004`, `PRJ-016` +- **Scenario IDs:** `I1-UI-03` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskDashboardServiceTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The public Task dashboard service reports blocked Tasks only for a Mentor's active owned Projects. For an Intern, it excludes former/completed memberships, counts current assigned Tasks, and returns at most five priority Tasks ordered by due date with null dates last and Task ID as the stable tie-breaker. + +## Test method + +Two focused Mockito tests provide Project service DTOs and verify the Task service result. The repository remains mocked so the test isolates role/project/member filtering and the Task-owned dashboard DTO boundary; PostgreSQL query ordering is verified by the affected integration suite. + +## Hand-derived expected result + +A Mentor with one active and one planned Project receives the active Project's four blocked Tasks only. An Intern with one current active membership, one former membership, and one completed Project receives six assigned Tasks and the due-first Task from the current Project. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDashboardServiceTest test +``` + +**Observed result** + +```text +[ERROR] TaskDashboardServiceTest.java:[34,13] cannot find symbol + symbol: class TaskDashboardService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDashboardServiceTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This test does not prove the shared dashboard controller/template, which belongs to `work/reports-ui`. PostgreSQL ordering, soft-delete filtering, and repository query syntax remain integration-test concerns. diff --git a/docs/tests/unit/task-mutation-boundary.md b/docs/tests/unit/task-mutation-boundary.md new file mode 100644 index 0000000..c2a787e --- /dev/null +++ b/docs/tests/unit/task-mutation-boundary.md @@ -0,0 +1,77 @@ +# Test Evidence: Task mutation authorization and locking boundary + +- **Test type:** Unit +- **Requirement IDs:** `AUTH-011`, `TSK-003`, `TSK-007`, `TSK-012`, `TSK-018` +- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`, `I1-TSK-04`, `AC-AUTH-010`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskMutationBoundaryTest` +- **Implementation commit:** `pending` + +## Protected behavior + +Every Task create/status/comment mutation first asks the concrete Project service for a current authorization context while holding the Project row lock. Status and comment mutations then load the Task with `PESSIMISTIC_WRITE` before checking or changing Task state. + +## Test method + +Three focused Mockito tests verify call order for create, status, and comment. They prove the Project mutation context precedes the Task write, the unlocked Project query is not used for create, and status/comment use the locked Task lookup before mutation. `TaskPersistenceStructureTest` separately inspects the real repository method's lock annotation, while the PostgreSQL workflow suite executes the query. + +## Hand-derived expected result + +Create calls `ProjectService.taskMutationContext(5, 10)` before saving. Status and comment call that same Project boundary, then `TaskRepository.findLockedByIdAndProjectIdAndDeletedAtIsNull(25, 10)`, before changing status or appending the comment. The Project service joins the outer Task transaction, so both locks remain through commit or rollback. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskMutationBoundaryTest test +``` + +**Observed result** + +```text +[ERROR] constructor TaskService ... cannot be applied to given types + required: TaskRepository,TaskCommentRepository,ProjectQueryService,CalendarApplicationService,Clock + found: TaskRepository,TaskCommentRepository,ProjectQueryService,ProjectService,CalendarApplicationService,Clock +[ERROR] cannot find symbol + symbol: method findLockedByIdAndProjectIdAndDeletedAtIsNull(long,long) +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskMutationBoundaryTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +These unit tests establish service call order and repository lock metadata; they do not simulate two concurrent database transactions. PostgreSQL execution of the locked Task lookup is covered by `TaskCreationIntegrationTest`, and the producing Project feature separately proves its locked DTO boundary. Broader concurrency stress remains the explicit Iteration 3 hardening scope. diff --git a/docs/tests/unit/task-persistence-structure.md b/docs/tests/unit/task-persistence-structure.md new file mode 100644 index 0000000..bd0c930 --- /dev/null +++ b/docs/tests/unit/task-persistence-structure.md @@ -0,0 +1,88 @@ +# Test Evidence: Task feature persistence structure + +- **Test type:** Unit +- **Requirement IDs:** `TSK-001`–`TSK-005`, `TSK-007`, `TSK-011`, `TSK-012` +- **Scenario IDs:** `I1-TSK-01`–`I1-TSK-04` +- **Test class/method:** `com.lab.labtimesheet.feature.task.repository.TaskPersistenceStructureTest#taskPersistenceUsesJpaEntitiesAndSpringDataRepositories` +- **Implementation commit:** `pending` + +## Protected behavior + +Task persistence uses JPA entities in `feature.task.model.entity` and Spring Data repositories in `feature.task.repository`. Status/comment mutation lookup is protected by `PESSIMISTIC_WRITE`. This prevents a regression to business-level JDBC access, unlocked mutation reads, or a global layer package. + +## Test method + +Four focused tests load the production `Task` and `TaskComment` classes, verify their `@Entity` annotations, verify that both production repository interfaces extend `JpaRepository`, reject direct JDBC imports in Task business code, and inspect the locked lookup's `@Lock(PESSIMISTIC_WRITE)` annotation. + +## Hand-derived expected result + +Exactly two Task-owned persisted aggregates are required for Iteration 1: `Task` and append-only `TaskComment`. Each must be a JPA entity, and each repository must be a Spring Data JPA repository under the Task feature package. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskPersistenceStructureTest test +``` + +**Observed result** + +```text +[ERROR] TaskPersistenceStructureTest.java:[5,54] package com.lab.labtimesheet.feature.task.model.entity does not exist +[ERROR] TaskPersistenceStructureTest.java:[6,54] package com.lab.labtimesheet.feature.task.model.entity does not exist +[INFO] BUILD FAILURE +``` + +The final feature-first package contract did not yet exist. + +After that package move reached GREEN, the business-persistence boundary was tightened with a second test and separately observed RED: + +```text +[ERROR] Tests run: 3, Failures: 2, Errors: 0, Skipped: 0 +Expecting [org.springframework.jdbc.core.simple.JdbcClient] +to contain [TaskRepository, TaskCommentRepository] +Expecting empty but was: [src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java] +[INFO] BUILD FAILURE +``` + +The second failure proves that `TaskService` still depended on direct JDBC instead of the two Task-owned Spring Data repositories. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskPersistenceStructureTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +The suite ran with approved escalation for PostgreSQL 18.4 Testcontainers and Mockito Java 25 self-attach. + +## External-test boundaries + +This structure test does not prove persistence mappings against PostgreSQL, transactional authorization, cross-feature service contracts, or rendered behavior. Those remain protected by the Task integration and web evidence after the dependency foundations are merged. diff --git a/docs/tests/unit/task-project-activation-query.md b/docs/tests/unit/task-project-activation-query.md new file mode 100644 index 0000000..db0dc09 --- /dev/null +++ b/docs/tests/unit/task-project-activation-query.md @@ -0,0 +1,74 @@ +# Test Evidence: Project activation Task-assignment query + +- **Test type:** Unit +- **Requirement IDs:** `PRJ-012` +- **Scenario IDs:** `I1-PRJ-04`, `AC-PRJ-006` +- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskQueryServiceTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The Project feature can ask the public Task service whether any current non-deleted Task is assigned outside the Project's active membership set, without accessing Task repositories or entities. + +## Test method + +Two focused Mockito tests exercise the concrete public service. An empty active-membership set counts every current Task without issuing an invalid `NOT IN ()` query. A non-empty set delegates to the filtered Spring Data repository query. + +## Hand-derived expected result + +With no active memberships, all three current Tasks are invalid assignments. With active memberships 7 and 9, the repository-derived count of assignments outside that set is two. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskQueryServiceTest test +``` + +**Observed result** + +```text +[ERROR] TaskQueryServiceTest.java:[22,13] cannot find symbol + symbol: class TaskQueryService +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskQueryServiceTest test +``` + +Run with approved sandbox escalation for Mockito Java 25 self-attach. + +**Observed result** + +```text +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test + +[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit test does not prove the JPQL query against PostgreSQL or Project activation integration. The Task PostgreSQL suite and the Project feature's own integration tests cover those boundaries after dependency merge. diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md index 9e31804..1b2691f 100644 --- a/docs/tests/unit/task-status-progress.md +++ b/docs/tests/unit/task-status-progress.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` - **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` -- **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` +- **Test class/method:** `com.lab.labtimesheet.feature.task.model.TaskDomainRulesTest` - **Implementation commit:** `17a3c5d` ## Protected behavior @@ -66,7 +66,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index d7db642..eaf24a3 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -3,21 +3,23 @@ - **Test type:** Web - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` -- **Test class/method:** `com.lab.labtimesheet.tasks.TaskControllerTest` +- **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` - **Implementation commit:** `pending` ## Protected behavior -Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, and show `N/A` for an empty Project. +Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. ## Test method -Six `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, and exception-to-status mapping. Only the PostgreSQL-backed Task service is replaced at the controller boundary. +Nine `@WebMvcTest` MockMvc tests render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. Only the PostgreSQL-backed Task service is replaced at the controller boundary. ## Hand-derived expected result Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. Valid status/comment posts redirect to Task 25. +When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. + ## RED **Command** @@ -38,6 +40,8 @@ export PATH="$JAVA_HOME/bin:$PATH" The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run. +The later view-capability increment was observed RED at test compilation because the Task DTOs did not yet provide the required capability and assignee fields. + ## GREEN **Command** @@ -53,7 +57,7 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach. **Observed result** ```text -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` @@ -67,7 +71,7 @@ export PATH="$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test -[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskController.java rename to src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java index 0188b1a..6838216 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskController.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/controller/TaskController.java @@ -1,5 +1,12 @@ -package com.lab.labtimesheet.tasks; +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; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java rename to src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java index dde94dd..00a77bc 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskNotFoundException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskNotFoundException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java rename to src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java index c32968b..0c2b66e 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskValidationException.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/exception/TaskValidationException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java similarity index 95% rename from src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java index 632d8e7..80fefbe 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskProgress.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; import java.util.Collection; import java.util.OptionalDouble; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java index 5aff97a..363accf 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/TaskStatus.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; public enum TaskStatus { TODO, diff --git a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java similarity index 79% rename from src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java index d10fc00..7ebbd28 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/CreateTaskCommand.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/CreateTaskCommand.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import java.time.LocalDate; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java similarity index 58% rename from src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java index 6e09bb1..d67e530 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskAssigneeChoice.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskAssigneeChoice.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; public record TaskAssigneeChoice(long membershipId, String displayName) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java similarity index 71% rename from src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java index 25be01e..1d3f800 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskCommentView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCommentView.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import java.time.Instant; diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java index fb0a92d..efe9815 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskCreateForm.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskCreateForm.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java new file mode 100644 index 0000000..d7ac141 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDashboardView.java @@ -0,0 +1,13 @@ +package com.lab.labtimesheet.feature.task.model.dto; + +import java.util.List; + +public record TaskDashboardView( + long blockedTaskCount, + long assignedTaskCount, + List priorityTasks) { + + public TaskDashboardView { + priorityTasks = List.copyOf(priorityTasks); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java new file mode 100644 index 0000000..9f9a91d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskDetails.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.task.model.dto; + +import java.util.List; + +public record TaskDetails( + TaskView task, + List comments, + boolean canChangeStatus, + boolean canComment) { + + public TaskDetails { + comments = List.copyOf(comments); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java similarity index 52% rename from src/main/java/com/lab/labtimesheet/tasks/TaskListView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java index 1acfed2..8049554 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskListView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskListView.java @@ -1,8 +1,9 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; +import com.lab.labtimesheet.feature.task.model.TaskProgress; import java.util.List; -public record TaskListView(List tasks, TaskProgress progress) { +public record TaskListView(List tasks, TaskProgress progress, boolean canCreate) { public TaskListView { tasks = List.copyOf(tasks); diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java new file mode 100644 index 0000000..30d09a7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskPriorityView.java @@ -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) {} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java similarity index 73% rename from src/main/java/com/lab/labtimesheet/tasks/TaskView.java rename to src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java index 4b0fb48..a466eee 100644 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskView.java +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/dto/TaskView.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model.dto; +import com.lab.labtimesheet.feature.task.model.TaskStatus; import java.time.Instant; import java.time.LocalDate; @@ -7,6 +8,7 @@ public record TaskView( long id, long projectId, long assigneeMembershipId, + String assigneeName, String title, String description, TaskStatus status, diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java new file mode 100644 index 0000000..59564d3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/Task.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java new file mode 100644 index 0000000..6e3b15e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/model/entity/TaskComment.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java new file mode 100644 index 0000000..f0073fc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskCommentRepository.java @@ -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 { + + List findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java new file mode 100644 index 0000000..1a8708e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/repository/TaskRepository.java @@ -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 { + + Optional findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId); + + List findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId); + + long countByProjectIdAndDeletedAtIsNull(long projectId); + + long countByProjectIdInAndStatusAndDeletedAtIsNull(List projectIds, TaskStatus status); + + long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( + List projectIds, List 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 findPriorityTasks( + @Param("projectIds") List projectIds, + @Param("assigneeMembershipIds") List 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 activeMembershipIds); +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java new file mode 100644 index 0000000..9540de2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskDashboardService.java @@ -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 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 activeProjects) { + List 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 activeProjects) { + Map currentProjects = new LinkedHashMap<>(); + Map 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 projectIds = List.copyOf(currentProjects.keySet()); + List membershipIds = List.copyOf(currentMemberships.values()); + if (projectIds.isEmpty()) { + return EMPTY_DASHBOARD; + } + + long assigned = tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull( + projectIds, membershipIds); + List 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()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java new file mode 100644 index 0000000..e469cf3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskQueryService.java @@ -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 activeMembershipIds) { + if (activeMembershipIds.isEmpty()) { + return tasks.countByProjectIdAndDeletedAtIsNull(projectId); + } + return tasks.countCurrentTasksAssignedOutside(projectId, Set.copyOf(activeMembershipIds)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java new file mode 100644 index 0000000..c8a44a8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java @@ -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 members = projectMembers(access); + List 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 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 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 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 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) {} +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java b/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java deleted file mode 100644 index 2cacfe7..0000000 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskDetails.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.lab.labtimesheet.tasks; - -import java.util.List; - -public record TaskDetails(TaskView task, List comments) { - - public TaskDetails { - comments = List.copyOf(comments); - } -} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java b/src/main/java/com/lab/labtimesheet/tasks/TaskService.java deleted file mode 100644 index 03fbe68..0000000 --- a/src/main/java/com/lab/labtimesheet/tasks/TaskService.java +++ /dev/null @@ -1,438 +0,0 @@ -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 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 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); - } - - @Transactional(readOnly = true) - public List assignmentChoices(String actorEmail, long projectId) { - Actor actor = requireActiveActor(actorEmail); - requireOpenProject(projectId); - long actorMembershipId = requireActorMembership(projectId, actor.id()); - boolean leader = isCurrentLeader(projectId, actorMembershipId); - return jdbc.sql(""" - select m.id, u.display_name - from project_memberships m - join app_users u on u.id = m.intern_user_id - join intern_profiles i on i.user_id = m.intern_user_id - where m.project_id = :projectId - and m.left_at is null - and u.account_status = 'ACTIVE' - and i.internship_status = 'ACTIVE' - and (:leader or m.id = :actorMembershipId) - order by m.id - """) - .param("projectId", projectId) - .param("leader", leader) - .param("actorMembershipId", actorMembershipId) - .query((rs, rowNum) -> new TaskAssigneeChoice( - rs.getLong("id"), rs.getString("display_name"))) - .list(); - } - - private Actor requireActiveActor(String email) { - 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) {} -} diff --git a/src/main/resources/templates/tasks/detail.html b/src/main/resources/templates/tasks/detail.html index 1e9ba7d..07d2417 100644 --- a/src/main/resources/templates/tasks/detail.html +++ b/src/main/resources/templates/tasks/detail.html @@ -9,10 +9,11 @@

Task

No description

+

Assignee: Assignee

Status: TODO

Due date:

-
+ diff --git a/src/main/resources/templates/tasks/list.html b/src/main/resources/templates/tasks/list.html index 7b830e0..a4d468a 100644 --- a/src/main/resources/templates/tasks/list.html +++ b/src/main/resources/templates/tasks/list.html @@ -15,13 +15,14 @@
BLOCKED
0
DONE
0
-

Create Task

+

Create Task

Authorized Projects
- + + diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java similarity index 67% rename from src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index 6f69df5..c3ec13e 100644 --- a/src/test/java/com/lab/labtimesheet/tasks/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -15,6 +15,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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; @@ -47,12 +57,25 @@ class TaskControllerTest { @Test void emptyTaskListRendersNotApplicableProgress() throws Exception { given(taskService.list(ACTOR_EMAIL, 10L)) - .willReturn(new TaskListView(List.of(), TaskProgress.from(List.of()))); + .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.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 @@ -123,10 +146,35 @@ class TaskControllerTest { .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, "Draft", "Notes", TaskStatus.TODO, + id, 10L, 7L, "Member Name", "Draft", "Notes", TaskStatus.TODO, LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant); } } diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java b/src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java similarity index 97% rename from src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java index af42eaf..f5f6988 100644 --- a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/model/TaskDomainRulesTest.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.tasks; +package com.lab.labtimesheet.feature.task.model; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java b/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java new file mode 100644 index 0000000..658eeb6 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/repository/TaskPersistenceStructureTest.java @@ -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(); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java similarity index 64% rename from src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java rename to src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java index ff52682..b4ffd70 100644 --- a/src/test/java/com/lab/labtimesheet/TaskCreationIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskCreationIntegrationTest.java @@ -1,19 +1,21 @@ -package com.lab.labtimesheet; +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.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.TaskAssigneeChoice; -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 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; @@ -35,9 +37,18 @@ class TaskCreationIntegrationTest { @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; @@ -73,6 +84,7 @@ class TaskCreationIntegrationTest { 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", @@ -208,6 +220,9 @@ class TaskCreationIntegrationTest { 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); @@ -215,8 +230,24 @@ class TaskCreationIntegrationTest { 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(); } @@ -234,6 +265,60 @@ class TaskCreationIntegrationTest { .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)) @@ -244,6 +329,45 @@ class TaskCreationIntegrationTest { .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 @@ -328,6 +452,26 @@ class TaskCreationIntegrationTest { 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", @@ -338,9 +482,34 @@ class TaskCreationIntegrationTest { 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, @@ -349,6 +518,7 @@ class TaskCreationIntegrationTest { """) .param("id", projectId) .update(); + entityManager.clear(); } private void setStatus(long taskId, TaskStatus status) { @@ -356,6 +526,7 @@ class TaskCreationIntegrationTest { .param("status", status.name()) .param("id", taskId) .update(); + entityManager.clear(); } private void softDelete(long taskId) { @@ -367,5 +538,26 @@ class TaskCreationIntegrationTest { .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(); } } diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java new file mode 100644 index 0000000..fafe9bb --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskDashboardServiceTest.java @@ -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 members) { + return new ProjectTaskContext( + id, + 3L, + "ACTIVE", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + null, + members); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java new file mode 100644 index 0000000..34e95fc --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskMutationBoundaryTest.java @@ -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; + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java new file mode 100644 index 0000000..abfb347 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/task/service/TaskQueryServiceTest.java @@ -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)); + } +} From 213a889c8f0a475abfdb06082065320379d9bc7a Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:23:25 +0700 Subject: [PATCH 12/12] docs(tests): finalize task evidence --- docs/tests/integration/task-workflow.md | 2 +- docs/tests/unit/task-dashboard-query.md | 2 +- docs/tests/unit/task-mutation-boundary.md | 2 +- docs/tests/unit/task-persistence-structure.md | 2 +- docs/tests/unit/task-project-activation-query.md | 2 +- docs/tests/web/task-pages.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tests/integration/task-workflow.md b/docs/tests/integration/task-workflow.md index 6d87de9..7cb7c5b 100644 --- a/docs/tests/integration/task-workflow.md +++ b/docs/tests/integration/task-workflow.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`–`AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001`–`TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018` - **Scenario IDs:** `I1-TSK-01`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003`–`AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-dashboard-query.md b/docs/tests/unit/task-dashboard-query.md index ec7dc52..95d9bb7 100644 --- a/docs/tests/unit/task-dashboard-query.md +++ b/docs/tests/unit/task-dashboard-query.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-003`–`AUTH-005`, `AUTH-009`, `TSK-001`, `TSK-002`, `TSK-004`, `PRJ-016` - **Scenario IDs:** `I1-UI-03` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskDashboardServiceTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-mutation-boundary.md b/docs/tests/unit/task-mutation-boundary.md index c2a787e..986b73f 100644 --- a/docs/tests/unit/task-mutation-boundary.md +++ b/docs/tests/unit/task-mutation-boundary.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-011`, `TSK-003`, `TSK-007`, `TSK-012`, `TSK-018` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`, `I1-TSK-04`, `AC-AUTH-010`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskMutationBoundaryTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-persistence-structure.md b/docs/tests/unit/task-persistence-structure.md index bd0c930..1ede20e 100644 --- a/docs/tests/unit/task-persistence-structure.md +++ b/docs/tests/unit/task-persistence-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `TSK-001`–`TSK-005`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`–`I1-TSK-04` - **Test class/method:** `com.lab.labtimesheet.feature.task.repository.TaskPersistenceStructureTest#taskPersistenceUsesJpaEntitiesAndSpringDataRepositories` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/unit/task-project-activation-query.md b/docs/tests/unit/task-project-activation-query.md index db0dc09..ccd9498 100644 --- a/docs/tests/unit/task-project-activation-query.md +++ b/docs/tests/unit/task-project-activation-query.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `PRJ-012` - **Scenario IDs:** `I1-PRJ-04`, `AC-PRJ-006` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskQueryServiceTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior diff --git a/docs/tests/web/task-pages.md b/docs/tests/web/task-pages.md index eaf24a3..0bd59ae 100644 --- a/docs/tests/web/task-pages.md +++ b/docs/tests/web/task-pages.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-007`, `TSK-011`, `TSK-012` - **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`–`I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest` -- **Implementation commit:** `pending` +- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968` ## Protected behavior
Current non-deleted Tasks
TitleStatusDue date
TitleAssigneeStatusDue date
TaskAssignee TODO