feat(task): adopt JPA feature boundaries

This commit is contained in:
sechmachine
2026-08-15 01:22:49 +07:00
parent 788d148d9c
commit 511ee81a91
39 changed files with 1722 additions and 494 deletions
+20 -7
View File
@@ -1,23 +1,25 @@
# Test Evidence: Iteration 1 Task persistence and authorization # Test Evidence: Iteration 1 Task persistence and authorization
- **Test type:** Integration - **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` - **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-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010` - **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.TaskCreationIntegrationTest` - **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest`
- **Implementation commit:** `pending` - **Implementation commit:** `pending`
## Protected behavior ## 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 ## 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 ## 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 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 ## RED
**Command** **Command**
@@ -51,6 +53,17 @@ After creation reached GREEN, the next cohesive workflow increment was separatel
[INFO] BUILD FAILURE [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 ## GREEN
**Command** **Command**
@@ -65,7 +78,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
**Observed result** **Observed result**
```text ```text
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 [INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS [INFO] BUILD SUCCESS
``` ```
@@ -79,7 +92,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./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 [INFO] BUILD SUCCESS
``` ```
+74
View File
@@ -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.
+77
View File
@@ -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.
@@ -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.
@@ -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.
+2 -2
View File
@@ -3,7 +3,7 @@
- **Test type:** Unit - **Test type:** Unit
- **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` - **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016`
- **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` - **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` - **Implementation commit:** `17a3c5d`
## Protected behavior ## Protected behavior
@@ -66,7 +66,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./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 [INFO] BUILD SUCCESS
``` ```
+9 -5
View File
@@ -3,21 +3,23 @@
- **Test type:** Web - **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` - **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` - **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` - **Implementation commit:** `pending`
## Protected behavior ## 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 ## 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 ## 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. 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 ## RED
**Command** **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 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 ## GREEN
**Command** **Command**
@@ -53,7 +57,7 @@ Run with approved sandbox escalation for Mockito Java 25 self-attach.
**Observed result** **Observed result**
```text ```text
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS [INFO] BUILD SUCCESS
``` ```
@@ -67,7 +71,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test ./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 [INFO] BUILD SUCCESS
``` ```
@@ -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 jakarta.validation.Valid;
import java.util.Locale; import java.util.Locale;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.exception;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model;
import java.util.Collection; import java.util.Collection;
import java.util.OptionalDouble; import java.util.OptionalDouble;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model;
public enum TaskStatus { public enum TaskStatus {
TODO, TODO,
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model.dto;
import java.time.LocalDate; import java.time.LocalDate;
@@ -1,3 +1,3 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model.dto;
public record TaskAssigneeChoice(long membershipId, String displayName) {} public record TaskAssigneeChoice(long membershipId, String displayName) {}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model.dto;
import java.time.Instant; import java.time.Instant;
@@ -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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
public record TaskDashboardView(
long blockedTaskCount,
long assignedTaskCount,
List<TaskPriorityView> priorityTasks) {
public TaskDashboardView {
priorityTasks = List.copyOf(priorityTasks);
}
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.task.model.dto;
import java.util.List;
public record TaskDetails(
TaskView task,
List<TaskCommentView> comments,
boolean canChangeStatus,
boolean canComment) {
public TaskDetails {
comments = List.copyOf(comments);
}
}
@@ -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; import java.util.List;
public record TaskListView(List<TaskView> tasks, TaskProgress progress) { public record TaskListView(List<TaskView> tasks, TaskProgress progress, boolean canCreate) {
public TaskListView { public TaskListView {
tasks = List.copyOf(tasks); tasks = List.copyOf(tasks);
@@ -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) {}
@@ -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.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -7,6 +8,7 @@ public record TaskView(
long id, long id,
long projectId, long projectId,
long assigneeMembershipId, long assigneeMembershipId,
String assigneeName,
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
@@ -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;
}
}
@@ -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;
}
}
@@ -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<TaskComment, Long> {
List<TaskComment> findAllByTaskIdOrderByCreatedAtAscIdAsc(long taskId);
}
@@ -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<Task, Long> {
Optional<Task> findByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Task> findLockedByIdAndProjectIdAndDeletedAtIsNull(long id, long projectId);
List<Task> findAllByProjectIdAndDeletedAtIsNullOrderById(long projectId);
long countByProjectIdAndDeletedAtIsNull(long projectId);
long countByProjectIdInAndStatusAndDeletedAtIsNull(List<Long> projectIds, TaskStatus status);
long countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
List<Long> projectIds, List<Long> 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<Task> findPriorityTasks(
@Param("projectIds") List<Long> projectIds,
@Param("assigneeMembershipIds") List<Long> 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<Long> activeMembershipIds);
}
@@ -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<ProjectSummary> 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<ProjectSummary> activeProjects) {
List<Long> 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<ProjectSummary> activeProjects) {
Map<Long, ProjectSummary> currentProjects = new LinkedHashMap<>();
Map<Long, Long> 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<Long> projectIds = List.copyOf(currentProjects.keySet());
List<Long> membershipIds = List.copyOf(currentMemberships.values());
if (projectIds.isEmpty()) {
return EMPTY_DASHBOARD;
}
long assigned = tasks.countByProjectIdInAndAssigneeMembershipIdInAndDeletedAtIsNull(
projectIds, membershipIds);
List<TaskPriorityView> 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());
}
}
@@ -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<Long> activeMembershipIds) {
if (activeMembershipIds.isEmpty()) {
return tasks.countByProjectIdAndDeletedAtIsNull(projectId);
}
return tasks.countCurrentTasksAssignedOutside(projectId, Set.copyOf(activeMembershipIds));
}
}
@@ -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<Long, ProjectMemberView> members = projectMembers(access);
List<TaskView> 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<TaskCommentView> 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<TaskAssigneeChoice> 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<Long, ProjectMemberView> 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<Long, ProjectMemberView> 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) {}
}
@@ -1,10 +0,0 @@
package com.lab.labtimesheet.tasks;
import java.util.List;
public record TaskDetails(TaskView task, List<TaskCommentView> comments) {
public TaskDetails {
comments = List.copyOf(comments);
}
}
@@ -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<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);
}
@Transactional(readOnly = true)
public List<TaskAssigneeChoice> 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) {}
}
@@ -9,10 +9,11 @@
<main> <main>
<h1 th:text="${details.task.title}">Task</h1> <h1 th:text="${details.task.title}">Task</h1>
<p th:text="${details.task.description ?: 'No description'}">No description</p> <p th:text="${details.task.description ?: 'No description'}">No description</p>
<p>Assignee: <strong th:text="${details.task.assigneeName}">Assignee</strong></p>
<p>Status: <strong th:text="${details.task.status}">TODO</strong></p> <p>Status: <strong th:text="${details.task.status}">TODO</strong></p>
<p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p> <p>Due date: <span th:text="${details.task.dueDate ?: '—'}"></span></p>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}"> <form th:if="${details.canChangeStatus}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/status(projectId=${projectId},taskId=${details.task.id})}">
<label for="status">New status</label> <label for="status">New status</label>
<select id="status" name="status" required> <select id="status" name="status" required>
<option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option> <option th:each="status : ${statuses}" th:value="${status}" th:text="${status}">TODO</option>
@@ -25,7 +26,7 @@
<ol> <ol>
<li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li> <li th:each="comment : ${details.comments}" th:text="${comment.body}">Comment</li>
</ol> </ol>
<form method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}"> <form th:if="${details.canComment}" method="post" th:action="@{/projects/{projectId}/tasks/{taskId}/comments(projectId=${projectId},taskId=${details.task.id})}">
<label for="body">Comment</label> <label for="body">Comment</label>
<textarea id="body" name="body" required></textarea> <textarea id="body" name="body" required></textarea>
<button type="submit">Add comment</button> <button type="submit">Add comment</button>
+3 -2
View File
@@ -15,13 +15,14 @@
<dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd> <dt>BLOCKED</dt><dd th:text="${taskList.progress.blocked}">0</dd>
<dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd> <dt>DONE</dt><dd th:text="${taskList.progress.done}">0</dd>
</dl> </dl>
<p><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p> <p th:if="${taskList.canCreate}"><a th:href="@{/projects/{projectId}/tasks/new(projectId=${projectId})}">Create Task</a></p>
<table> <table>
<caption>Current non-deleted Tasks</caption> <caption>Current non-deleted Tasks</caption>
<thead><tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead> <thead><tr><th scope="col">Title</th><th scope="col">Assignee</th><th scope="col">Status</th><th scope="col">Due date</th></tr></thead>
<tbody> <tbody>
<tr th:each="task : ${taskList.tasks}"> <tr th:each="task : ${taskList.tasks}">
<td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td> <td><a th:href="@{/projects/{projectId}/tasks/{taskId}(projectId=${projectId},taskId=${task.id})}" th:text="${task.title}">Task</a></td>
<td th:text="${task.assigneeName}">Assignee</td>
<td th:text="${task.status}">TODO</td> <td th:text="${task.status}">TODO</td>
<td th:text="${task.dueDate ?: '—'}"></td> <td th:text="${task.dueDate ?: '—'}"></td>
</tr> </tr>
@@ -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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; 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.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 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.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@@ -47,12 +57,25 @@ class TaskControllerTest {
@Test @Test
void emptyTaskListRendersNotApplicableProgress() throws Exception { void emptyTaskListRendersNotApplicableProgress() throws Exception {
given(taskService.list(ACTOR_EMAIL, 10L)) 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))) mockMvc.perform(get("/projects/10/tasks").with(user(ACTOR_EMAIL)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(view().name("tasks/list")) .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 @Test
@@ -123,10 +146,35 @@ class TaskControllerTest {
.andExpect(redirectedUrl("/projects/10/tasks/25")); .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) { private static TaskView task(long id) {
Instant instant = Instant.parse("2026-08-14T10:00:00Z"); Instant instant = Instant.parse("2026-08-14T10:00:00Z");
return new TaskView( 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); LocalDate.of(2026, 8, 20), 7L, 7L, instant, instant);
} }
} }
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.tasks; package com.lab.labtimesheet.feature.task.model;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -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();
}
}
}
@@ -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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.lab.labtimesheet.tasks.CreateTaskCommand; import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.tasks.TaskCommentView; import com.lab.labtimesheet.feature.task.exception.TaskNotFoundException;
import com.lab.labtimesheet.tasks.TaskDetails; import com.lab.labtimesheet.feature.task.exception.TaskValidationException;
import com.lab.labtimesheet.tasks.TaskListView; import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.tasks.TaskAssigneeChoice; import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
import com.lab.labtimesheet.tasks.TaskNotFoundException; import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.tasks.TaskService; import com.lab.labtimesheet.feature.task.model.dto.TaskCommentView;
import com.lab.labtimesheet.tasks.TaskStatus; import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
import com.lab.labtimesheet.tasks.TaskValidationException; import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
import com.lab.labtimesheet.tasks.TaskView; import com.lab.labtimesheet.feature.task.model.dto.TaskView;
import jakarta.persistence.EntityManager;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -35,9 +37,18 @@ class TaskCreationIntegrationTest {
@Autowired @Autowired
private JdbcClient jdbc; private JdbcClient jdbc;
@Autowired
private EntityManager entityManager;
@Autowired @Autowired
private TaskService taskService; private TaskService taskService;
@Autowired
private TaskQueryService taskQueries;
@Autowired
private TaskDashboardService taskDashboard;
private long projectId; private long projectId;
private long leaderMembershipId; private long leaderMembershipId;
private long memberMembershipId; private long memberMembershipId;
@@ -73,6 +84,7 @@ class TaskCreationIntegrationTest {
assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId); assertThat(task.creatorMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId); assertThat(task.assignerMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId); assertThat(task.assigneeMembershipId()).isEqualTo(memberMembershipId);
assertThat(task.assigneeName()).isEqualTo("member@example.test");
assertThatThrownBy(() -> taskService.create( assertThatThrownBy(() -> taskService.create(
"member@example.test", "member@example.test",
@@ -208,6 +220,9 @@ class TaskCreationIntegrationTest {
assertThat(list.tasks()).extracting(TaskView::title) assertThat(list.tasks()).extracting(TaskView::title)
.containsExactly("Todo", "Active", "Blocked", "Done"); .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().total()).isEqualTo(4);
assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1); assertThat(list.progress().count(TaskStatus.TODO)).isEqualTo(1);
assertThat(list.progress().count(TaskStatus.IN_PROGRESS)).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().count(TaskStatus.DONE)).isEqualTo(1);
assertThat(list.progress().completionPercentage()).hasValue(25.0); assertThat(list.progress().completionPercentage()).hasValue(25.0);
assertThat(details.comments()).extracting(TaskCommentView::body).containsExactly("Visible comment"); 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 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()) assertThat(taskService.list("mentor@example.test", emptyProjectId).progress().completionPercentage())
.isEmpty(); .isEmpty();
} }
@@ -234,6 +265,60 @@ class TaskCreationIntegrationTest {
.isInstanceOf(TaskNotFoundException.class); .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 @Test
void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() { void createFormChoicesAreSelfOnlyForMembersAndAllActiveMembersForLeader() {
assertThat(taskService.assignmentChoices("member@example.test", projectId)) assertThat(taskService.assignmentChoices("member@example.test", projectId))
@@ -244,6 +329,45 @@ class TaskCreationIntegrationTest {
.containsExactly(leaderMembershipId, memberMembershipId); .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) { private long insertUser(String email, String role) {
return jdbc.sql(""" return jdbc.sql("""
insert into app_users insert into app_users
@@ -328,6 +452,26 @@ class TaskCreationIntegrationTest {
return jdbc.sql("select count(*) from task_comments").query(Long.class).single(); 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) { private TaskView createMemberTask(String title) {
return taskService.create( return taskService.create(
"member@example.test", "member@example.test",
@@ -338,9 +482,34 @@ class TaskCreationIntegrationTest {
jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id") jdbc.sql("update projects set status = 'ACTIVE', activated_at = current_timestamp where id = :id")
.param("id", projectId) .param("id", projectId)
.update(); .update();
entityManager.clear();
} }
private void completeProject() { 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(""" jdbc.sql("""
update projects update projects
set status = 'COMPLETED', activated_at = current_timestamp, set status = 'COMPLETED', activated_at = current_timestamp,
@@ -349,6 +518,7 @@ class TaskCreationIntegrationTest {
""") """)
.param("id", projectId) .param("id", projectId)
.update(); .update();
entityManager.clear();
} }
private void setStatus(long taskId, TaskStatus status) { private void setStatus(long taskId, TaskStatus status) {
@@ -356,6 +526,7 @@ class TaskCreationIntegrationTest {
.param("status", status.name()) .param("status", status.name())
.param("id", taskId) .param("id", taskId)
.update(); .update();
entityManager.clear();
} }
private void softDelete(long taskId) { private void softDelete(long taskId) {
@@ -367,5 +538,26 @@ class TaskCreationIntegrationTest {
.param("membershipId", memberMembershipId) .param("membershipId", memberMembershipId)
.param("id", taskId) .param("id", taskId)
.update(); .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();
} }
} }
@@ -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<ProjectTaskMemberView> members) {
return new ProjectTaskContext(
id,
3L,
"ACTIVE",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 8, 31),
null,
members);
}
}
@@ -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;
}
}
@@ -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));
}
}