feat(tasks): persist iteration 1 task workflow
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateTaskCommand(
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
LocalDate dueDate) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record TaskCommentView(long id, long taskId, long authorUserId, String body, Instant createdAt) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskDetails(TaskView task, List<TaskCommentView> comments) {
|
||||
|
||||
public TaskDetails {
|
||||
comments = List.copyOf(comments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TaskListView(List<TaskView> tasks, TaskProgress progress) {
|
||||
|
||||
public TaskListView {
|
||||
tasks = List.copyOf(tasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public final class TaskNotFoundException extends RuntimeException {
|
||||
|
||||
public TaskNotFoundException() {
|
||||
super("Task or Project was not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class TaskService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
|
||||
public TaskService(JdbcClient jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView create(String actorEmail, CreateTaskCommand command) {
|
||||
String title = requireTitle(command.title());
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
Project project = requireOpenProject(command.projectId());
|
||||
long actorMembershipId = requireActorMembership(project.id(), actor.id());
|
||||
requireAssigneeMembership(project.id(), command.assigneeMembershipId());
|
||||
|
||||
if (actorMembershipId != command.assigneeMembershipId()
|
||||
&& !isCurrentLeader(project.id(), actorMembershipId)) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
validateDueDate(project, command.dueDate());
|
||||
|
||||
long taskId = jdbc.sql("""
|
||||
insert into tasks
|
||||
(project_id, assignee_membership_id, title, description, due_date,
|
||||
created_by_membership_id, assigned_by_membership_id)
|
||||
values (:projectId, :assigneeId, :title, :description, :dueDate,
|
||||
:actorMembershipId, :actorMembershipId)
|
||||
returning id
|
||||
""")
|
||||
.param("projectId", project.id())
|
||||
.param("assigneeId", command.assigneeMembershipId())
|
||||
.param("title", title)
|
||||
.param("description", trimToNull(command.description()))
|
||||
.param("dueDate", command.dueDate())
|
||||
.param("actorMembershipId", actorMembershipId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
return task(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView changeStatus(String actorEmail, long projectId, long taskId, TaskStatus target) {
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
TaskView task = jdbc.sql("""
|
||||
select t.id, t.project_id, t.assignee_membership_id, t.title, t.description,
|
||||
t.status, t.due_date, t.created_by_membership_id,
|
||||
t.assigned_by_membership_id, t.assigned_at, t.created_at
|
||||
from tasks t
|
||||
join projects p on p.id = t.project_id
|
||||
join project_memberships m on m.id = t.assignee_membership_id
|
||||
and m.project_id = t.project_id
|
||||
where t.id = :taskId
|
||||
and t.project_id = :projectId
|
||||
and t.deleted_at is null
|
||||
and p.status = 'ACTIVE'
|
||||
and m.intern_user_id = :actorId
|
||||
and m.left_at is null
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actor.id())
|
||||
.query(TaskService::mapTask)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
if (!task.status().canTransitionTo(target)) {
|
||||
throw new TaskValidationException("Task status transition is not allowed");
|
||||
}
|
||||
jdbc.sql("update tasks set status = :status, updated_at = current_timestamp where id = :taskId")
|
||||
.param("status", target.name())
|
||||
.param("taskId", taskId)
|
||||
.update();
|
||||
return task(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskCommentView addComment(String actorEmail, long projectId, long taskId, String body) {
|
||||
String normalizedBody = requireCommentBody(body);
|
||||
Actor actor = requireActiveActor(actorEmail);
|
||||
ProjectAccess project = requireProjectAccess(projectId);
|
||||
if ("COMPLETED".equals(project.status()) || !taskExists(projectId, taskId)) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
if (actor.id() != project.mentorUserId() && !hasActiveMembership(projectId, actor.id())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
|
||||
long commentId = jdbc.sql("""
|
||||
insert into task_comments (task_id, author_user_id, body)
|
||||
values (:taskId, :actorId, :body)
|
||||
returning id
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("actorId", actor.id())
|
||||
.param("body", normalizedBody)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
return comment(commentId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskListView list(String actorEmail, long projectId) {
|
||||
requireViewAccess(actorEmail, projectId);
|
||||
List<TaskView> tasks = jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where project_id = :projectId and deleted_at is null
|
||||
order by id
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(TaskService::mapTask)
|
||||
.list();
|
||||
return new TaskListView(tasks, TaskProgress.from(tasks.stream().map(TaskView::status).toList()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskDetails details(String actorEmail, long projectId, long taskId) {
|
||||
requireViewAccess(actorEmail, projectId);
|
||||
TaskView task = jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where id = :taskId and project_id = :projectId and deleted_at is null
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.query(TaskService::mapTask)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
List<TaskCommentView> comments = jdbc.sql("""
|
||||
select id, task_id, author_user_id, body, created_at
|
||||
from task_comments
|
||||
where task_id = :taskId
|
||||
order by created_at, id
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.query(TaskService::mapComment)
|
||||
.list();
|
||||
return new TaskDetails(task, comments);
|
||||
}
|
||||
|
||||
private Actor requireActiveActor(String email) {
|
||||
Actor actor = requireReadableActor(email);
|
||||
if ("INTERN".equals(actor.role()) && !"ACTIVE".equals(actor.internshipStatus())) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
private Actor requireReadableActor(String email) {
|
||||
return jdbc.sql("""
|
||||
select u.id, u.global_role, i.internship_status
|
||||
from app_users u
|
||||
left join intern_profiles i on i.user_id = u.id
|
||||
where lower(btrim(u.email)) = lower(btrim(:email))
|
||||
and u.account_status = 'ACTIVE'
|
||||
""")
|
||||
.param("email", email)
|
||||
.query((rs, rowNum) -> new Actor(
|
||||
rs.getLong("id"),
|
||||
rs.getString("global_role"),
|
||||
rs.getString("internship_status")))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private Project requireOpenProject(long projectId) {
|
||||
return jdbc.sql("""
|
||||
select id, status, start_date, end_date
|
||||
from projects
|
||||
where id = :projectId and status in ('PLANNED', 'ACTIVE')
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query((rs, rowNum) -> new Project(
|
||||
rs.getLong("id"),
|
||||
rs.getString("status"),
|
||||
rs.getObject("start_date", LocalDate.class),
|
||||
rs.getObject("end_date", LocalDate.class)))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private long requireActorMembership(long projectId, long userId) {
|
||||
return jdbc.sql("""
|
||||
select m.id
|
||||
from project_memberships m
|
||||
join app_users u on u.id = m.intern_user_id
|
||||
join intern_profiles i on i.user_id = m.intern_user_id
|
||||
where m.project_id = :projectId
|
||||
and m.intern_user_id = :userId
|
||||
and m.left_at is null
|
||||
and u.account_status = 'ACTIVE'
|
||||
and i.internship_status = 'ACTIVE'
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("userId", userId)
|
||||
.query(Long.class)
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private void requireAssigneeMembership(long projectId, long membershipId) {
|
||||
boolean exists = jdbc.sql("""
|
||||
select exists (
|
||||
select 1
|
||||
from project_memberships m
|
||||
join app_users u on u.id = m.intern_user_id
|
||||
join intern_profiles i on i.user_id = m.intern_user_id
|
||||
where m.id = :membershipId
|
||||
and m.project_id = :projectId
|
||||
and m.left_at is null
|
||||
and u.account_status = 'ACTIVE'
|
||||
and i.internship_status = 'ACTIVE'
|
||||
)
|
||||
""")
|
||||
.param("membershipId", membershipId)
|
||||
.param("projectId", projectId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (!exists) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentLeader(long projectId, long membershipId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_leadership_terms
|
||||
where project_id = :projectId
|
||||
and membership_id = :membershipId
|
||||
and ended_at is null
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("membershipId", membershipId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private void requireViewAccess(String actorEmail, long projectId) {
|
||||
Actor actor = requireReadableActor(actorEmail);
|
||||
ProjectAccess project = requireProjectAccess(projectId);
|
||||
if ("ADMIN".equals(actor.role()) || actor.id() == project.mentorUserId()) {
|
||||
return;
|
||||
}
|
||||
boolean member = jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_memberships
|
||||
where project_id = :projectId
|
||||
and intern_user_id = :actorId
|
||||
and (:completed or left_at is null)
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actor.id())
|
||||
.param("completed", "COMPLETED".equals(project.status()))
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (!member) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectAccess requireProjectAccess(long projectId) {
|
||||
return jdbc.sql("select status, mentor_user_id from projects where id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.query((rs, rowNum) -> new ProjectAccess(
|
||||
rs.getString("status"), rs.getLong("mentor_user_id")))
|
||||
.optional()
|
||||
.orElseThrow(TaskNotFoundException::new);
|
||||
}
|
||||
|
||||
private boolean hasActiveMembership(long projectId, long actorId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from project_memberships
|
||||
where project_id = :projectId
|
||||
and intern_user_id = :actorId
|
||||
and left_at is null
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("actorId", actorId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private boolean taskExists(long projectId, long taskId) {
|
||||
return jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from tasks
|
||||
where id = :taskId and project_id = :projectId and deleted_at is null
|
||||
)
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.param("projectId", projectId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private void validateDueDate(Project project, LocalDate dueDate) {
|
||||
if (dueDate == null) {
|
||||
return;
|
||||
}
|
||||
if (dueDate.isBefore(project.startDate()) || dueDate.isAfter(project.endDate())) {
|
||||
throw new TaskValidationException("Due date must be within Project dates");
|
||||
}
|
||||
boolean dayOff = jdbc.sql("""
|
||||
select exists (
|
||||
select 1 from global_calendar_events
|
||||
where calendar_date = :dueDate and is_day_off = true
|
||||
)
|
||||
""")
|
||||
.param("dueDate", dueDate)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (dayOff) {
|
||||
throw new TaskValidationException("Due date cannot be a current global day off");
|
||||
}
|
||||
}
|
||||
|
||||
private TaskView task(long taskId) {
|
||||
return jdbc.sql("""
|
||||
select id, project_id, assignee_membership_id, title, description, status,
|
||||
due_date, created_by_membership_id, assigned_by_membership_id,
|
||||
assigned_at, created_at
|
||||
from tasks
|
||||
where id = :taskId
|
||||
""")
|
||||
.param("taskId", taskId)
|
||||
.query(TaskService::mapTask)
|
||||
.single();
|
||||
}
|
||||
|
||||
private TaskCommentView comment(long commentId) {
|
||||
return jdbc.sql("""
|
||||
select id, task_id, author_user_id, body, created_at
|
||||
from task_comments
|
||||
where id = :commentId
|
||||
""")
|
||||
.param("commentId", commentId)
|
||||
.query(TaskService::mapComment)
|
||||
.single();
|
||||
}
|
||||
|
||||
private static TaskView mapTask(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TaskView(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("project_id"),
|
||||
rs.getLong("assignee_membership_id"),
|
||||
rs.getString("title"),
|
||||
rs.getString("description"),
|
||||
TaskStatus.valueOf(rs.getString("status")),
|
||||
rs.getObject("due_date", LocalDate.class),
|
||||
rs.getLong("created_by_membership_id"),
|
||||
rs.getLong("assigned_by_membership_id"),
|
||||
rs.getTimestamp("assigned_at").toInstant(),
|
||||
rs.getTimestamp("created_at").toInstant());
|
||||
}
|
||||
|
||||
private static TaskCommentView mapComment(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new TaskCommentView(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("task_id"),
|
||||
rs.getLong("author_user_id"),
|
||||
rs.getString("body"),
|
||||
rs.getTimestamp("created_at").toInstant());
|
||||
}
|
||||
|
||||
private static String requireTitle(String title) {
|
||||
String trimmed = trimToNull(title);
|
||||
if (trimmed == null || trimmed.length() > 200) {
|
||||
throw new TaskValidationException("Title is required and must not exceed 200 characters");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String requireCommentBody(String body) {
|
||||
String trimmed = trimToNull(body);
|
||||
if (trimmed == null) {
|
||||
throw new TaskValidationException("Comment body is required");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private record Actor(long id, String role, String internshipStatus) {}
|
||||
|
||||
private record Project(long id, String status, LocalDate startDate, LocalDate endDate) {}
|
||||
|
||||
private record ProjectAccess(String status, long mentorUserId) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public final class TaskValidationException extends RuntimeException {
|
||||
|
||||
public TaskValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lab.labtimesheet.tasks;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TaskView(
|
||||
long id,
|
||||
long projectId,
|
||||
long assigneeMembershipId,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
LocalDate dueDate,
|
||||
long creatorMembershipId,
|
||||
long assignerMembershipId,
|
||||
Instant assignedAt,
|
||||
Instant createdAt) {}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.lab.labtimesheet.tasks.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.tasks.TaskCommentView;
|
||||
import com.lab.labtimesheet.tasks.TaskDetails;
|
||||
import com.lab.labtimesheet.tasks.TaskListView;
|
||||
import com.lab.labtimesheet.tasks.TaskNotFoundException;
|
||||
import com.lab.labtimesheet.tasks.TaskService;
|
||||
import com.lab.labtimesheet.tasks.TaskStatus;
|
||||
import com.lab.labtimesheet.tasks.TaskValidationException;
|
||||
import com.lab.labtimesheet.tasks.TaskView;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class TaskCreationIntegrationTest {
|
||||
|
||||
private static final LocalDate PROJECT_START = LocalDate.of(2026, 8, 1);
|
||||
private static final LocalDate PROJECT_END = LocalDate.of(2026, 8, 31);
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbc;
|
||||
|
||||
@Autowired
|
||||
private TaskService taskService;
|
||||
|
||||
private long projectId;
|
||||
private long leaderMembershipId;
|
||||
private long memberMembershipId;
|
||||
|
||||
@BeforeEach
|
||||
void setUpProject() {
|
||||
long mentorId = insertUser("mentor@example.test", "MENTOR");
|
||||
long leaderId = insertIntern("leader@example.test");
|
||||
long memberId = insertIntern("member@example.test");
|
||||
projectId = insertProject(mentorId, "PLANNED");
|
||||
leaderMembershipId = insertMembership(projectId, leaderId, mentorId);
|
||||
memberMembershipId = insertMembership(projectId, memberId, mentorId);
|
||||
jdbc.sql("""
|
||||
insert into project_leadership_terms
|
||||
(project_id, membership_id, appointed_by_mentor_user_id)
|
||||
values (:projectId, :membershipId, :mentorId)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("membershipId", leaderMembershipId)
|
||||
.param("mentorId", mentorId)
|
||||
.update();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeMemberCreatesOnlyASelfAssignedTaskWithEqualActors() {
|
||||
TaskView task = taskService.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, " Draft results ", " notes ", PROJECT_START));
|
||||
|
||||
assertThat(task.status()).isEqualTo(TaskStatus.TODO);
|
||||
assertThat(task.title()).isEqualTo("Draft results");
|
||||
assertThat(task.description()).isEqualTo("notes");
|
||||
assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId);
|
||||
assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId);
|
||||
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
|
||||
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(projectId, leaderMembershipId, "Forbidden", null, null)))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThat(taskCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentLeaderCreatesForAnotherActiveSameProjectMember() {
|
||||
TaskView task = taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Review results", null, PROJECT_END));
|
||||
|
||||
assertThat(task.creatorMembershipId()).isEqualTo(leaderMembershipId);
|
||||
assertThat(task.assignerMembershipId()).isEqualTo(leaderMembershipId);
|
||||
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossProjectAndInactiveAssigneesWithoutWriting() {
|
||||
long mentorId = userId("mentor@example.test");
|
||||
long outsiderId = insertIntern("outsider@example.test");
|
||||
long otherProjectId = insertProject(mentorId, "PLANNED");
|
||||
long otherMembershipId = insertMembership(otherProjectId, outsiderId, mentorId);
|
||||
jdbc.sql("update project_memberships set left_at = joined_at + interval '1 second', removed_by_mentor_user_id = :mentorId where id = :id")
|
||||
.param("mentorId", mentorId)
|
||||
.param("id", memberMembershipId)
|
||||
.update();
|
||||
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, otherMembershipId, "Cross project", null, null)))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Inactive", null, null)))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThat(taskCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsProjectBoundaryDueDatesAndRejectsOutsideOrCurrentDayOff() {
|
||||
taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Start boundary", null, PROJECT_START));
|
||||
taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "End boundary", null, PROJECT_END));
|
||||
insertDayOff(LocalDate.of(2026, 8, 15));
|
||||
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Before", null, PROJECT_START.minusDays(1))))
|
||||
.isInstanceOf(TaskValidationException.class);
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "After", null, PROJECT_END.plusDays(1))))
|
||||
.isInstanceOf(TaskValidationException.class);
|
||||
assertThatThrownBy(() -> taskService.create(
|
||||
"leader@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Day off", null, LocalDate.of(2026, 8, 15))))
|
||||
.isInstanceOf(TaskValidationException.class);
|
||||
assertThat(taskCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyCurrentAssigneeChangesStatusOnAnActiveProject() {
|
||||
TaskView task = taskService.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Run experiment", null, null));
|
||||
|
||||
assertThatThrownBy(() -> taskService.changeStatus(
|
||||
"member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
activateProject();
|
||||
assertThatThrownBy(() -> taskService.changeStatus(
|
||||
"leader@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
|
||||
TaskView inProgress = taskService.changeStatus(
|
||||
"member@example.test", projectId, task.id(), TaskStatus.IN_PROGRESS);
|
||||
|
||||
assertThat(inProgress.status()).isEqualTo(TaskStatus.IN_PROGRESS);
|
||||
assertThatThrownBy(() -> taskService.changeStatus(
|
||||
"member@example.test", projectId, task.id(), TaskStatus.TODO))
|
||||
.isInstanceOf(TaskValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeMemberAndOwningMentorAppendCommentsUntilProjectCompletion() {
|
||||
TaskView task = taskService.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, "Discuss results", null, null));
|
||||
insertIntern("outsider@example.test");
|
||||
|
||||
TaskCommentView memberComment = taskService.addComment(
|
||||
"member@example.test", projectId, task.id(), " First note ");
|
||||
TaskCommentView mentorComment = taskService.addComment(
|
||||
"mentor@example.test", projectId, task.id(), "Mentor note");
|
||||
|
||||
assertThat(memberComment.body()).isEqualTo("First note");
|
||||
assertThat(mentorComment.authorUserId()).isEqualTo(userId("mentor@example.test"));
|
||||
assertThatThrownBy(() -> taskService.addComment(
|
||||
"outsider@example.test", projectId, task.id(), "Forbidden"))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThatThrownBy(() -> taskService.addComment(
|
||||
"member@example.test", projectId, task.id(), " "))
|
||||
.isInstanceOf(TaskValidationException.class);
|
||||
|
||||
completeProject();
|
||||
assertThatThrownBy(() -> taskService.addComment(
|
||||
"mentor@example.test", projectId, task.id(), "Too late"))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThat(commentCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizedListsAndDetailsExcludeDeletedTasksAndReportEmptyAsNotApplicable() {
|
||||
TaskView todo = createMemberTask("Todo");
|
||||
TaskView active = createMemberTask("Active");
|
||||
TaskView blocked = createMemberTask("Blocked");
|
||||
TaskView done = createMemberTask("Done");
|
||||
TaskView deleted = createMemberTask("Deleted");
|
||||
setStatus(active.id(), TaskStatus.IN_PROGRESS);
|
||||
setStatus(blocked.id(), TaskStatus.BLOCKED);
|
||||
setStatus(done.id(), TaskStatus.DONE);
|
||||
softDelete(deleted.id());
|
||||
taskService.addComment("member@example.test", projectId, todo.id(), "Visible comment");
|
||||
|
||||
TaskListView list = taskService.list("member@example.test", projectId);
|
||||
TaskDetails details = taskService.details("mentor@example.test", projectId, todo.id());
|
||||
|
||||
assertThat(list.tasks()).extracting(TaskView::title)
|
||||
.containsExactly("Todo", "Active", "Blocked", "Done");
|
||||
assertThat(list.progress().total()).isEqualTo(4);
|
||||
assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1);
|
||||
assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).isEqualTo(1);
|
||||
assertThat(list.progress().count(TaskStatus.BLOCKED)).isEqualTo(1);
|
||||
assertThat(list.progress().count(TaskStatus.DONE)).isEqualTo(1);
|
||||
assertThat(list.progress().completionPercentage()).hasValue(25.0);
|
||||
assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment");
|
||||
|
||||
long emptyProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
|
||||
assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage())
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directAndCrossProjectTaskIdentifiersDoNotDiscloseRecords() {
|
||||
TaskView task = createMemberTask("Private task");
|
||||
long otherProjectId = insertProject(userId("mentor@example.test"), "PLANNED");
|
||||
|
||||
assertThatThrownBy(() -> taskService.details(
|
||||
"mentor@example.test", otherProjectId, task.id()))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
assertThatThrownBy(() -> taskService.details(
|
||||
"outsider@example.test", projectId, task.id()))
|
||||
.isInstanceOf(TaskNotFoundException.class);
|
||||
}
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
return jdbc.sql("""
|
||||
insert into app_users
|
||||
(email, display_name, password_hash, global_role, account_status, activated_at)
|
||||
values (:email, :email, 'hash', :role, 'ACTIVE', current_timestamp)
|
||||
returning id
|
||||
""")
|
||||
.param("email", email)
|
||||
.param("role", role)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private long insertIntern(String email) {
|
||||
long userId = insertUser(email, "INTERN");
|
||||
jdbc.sql("""
|
||||
insert into intern_profiles
|
||||
(user_id, student_code, internship_start_date, internship_end_date,
|
||||
internship_status, activated_at)
|
||||
values (:userId, :studentCode, date '2026-01-01', date '2026-12-31',
|
||||
'ACTIVE', current_timestamp)
|
||||
""")
|
||||
.param("userId", userId)
|
||||
.param("studentCode", "S" + userId)
|
||||
.update();
|
||||
return userId;
|
||||
}
|
||||
|
||||
private long insertProject(long mentorId, String status) {
|
||||
return jdbc.sql("""
|
||||
insert into projects
|
||||
(mentor_user_id, name, status, start_date, end_date, activated_at)
|
||||
values (:mentorId, 'Project', :status, :startDate, :endDate,
|
||||
case when :status = 'ACTIVE' then current_timestamp else null end)
|
||||
returning id
|
||||
""")
|
||||
.param("mentorId", mentorId)
|
||||
.param("status", status)
|
||||
.param("startDate", PROJECT_START)
|
||||
.param("endDate", PROJECT_END)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private long insertMembership(long targetProjectId, long internId, long mentorId) {
|
||||
return jdbc.sql("""
|
||||
insert into project_memberships (project_id, intern_user_id, added_by_user_id)
|
||||
values (:projectId, :internId, :mentorId)
|
||||
returning id
|
||||
""")
|
||||
.param("projectId", targetProjectId)
|
||||
.param("internId", internId)
|
||||
.param("mentorId", mentorId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private void insertDayOff(LocalDate date) {
|
||||
long mentorId = userId("mentor@example.test");
|
||||
jdbc.sql("""
|
||||
insert into global_calendar_events
|
||||
(calendar_date, name, source, is_day_off, created_by_user_id, updated_by_user_id)
|
||||
values (:date, 'Day off', 'CUSTOM', true, :userId, :userId)
|
||||
""")
|
||||
.param("date", date)
|
||||
.param("userId", mentorId)
|
||||
.update();
|
||||
}
|
||||
|
||||
private long userId(String email) {
|
||||
return jdbc.sql("select id from app_users where email = :email")
|
||||
.param("email", email)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
private long taskCount() {
|
||||
return jdbc.sql("select count(*) from tasks").query(Long.class).single();
|
||||
}
|
||||
|
||||
private long commentCount() {
|
||||
return jdbc.sql("select count(*) from task_comments").query(Long.class).single();
|
||||
}
|
||||
|
||||
private TaskView createMemberTask(String title) {
|
||||
return taskService.create(
|
||||
"member@example.test",
|
||||
new CreateTaskCommand(projectId, memberMembershipId, title, null, null));
|
||||
}
|
||||
|
||||
private void activateProject() {
|
||||
jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
}
|
||||
|
||||
private void completeProject() {
|
||||
jdbc.sql("""
|
||||
update projects
|
||||
set status = 'COMPLETED', activated_at = current_timestamp,
|
||||
completed_at = current_timestamp
|
||||
where id = :id
|
||||
""")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
}
|
||||
|
||||
private void setStatus(long taskId, TaskStatus status) {
|
||||
jdbc.sql("update tasks set status = :status where id = :id")
|
||||
.param("status", status.name())
|
||||
.param("id", taskId)
|
||||
.update();
|
||||
}
|
||||
|
||||
private void softDelete(long taskId) {
|
||||
jdbc.sql("""
|
||||
update tasks
|
||||
set deleted_at = current_timestamp, deleted_by_membership_id = :membershipId
|
||||
where id = :id
|
||||
""")
|
||||
.param("membershipId", memberMembershipId)
|
||||
.param("id", taskId)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user