Merge commit 'c3f92d4e310d625d95d374e974c478444331f6d7' into work/tasks
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Test Evidence: Atomic Project workflows
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007`
|
||||||
|
- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest`
|
||||||
|
- **Implementation commit:** `25a855e`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, and enforce role/membership visibility without ID disclosure.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testcontainer and Flyway V1 schema. It calls the public Project service and verifies committed-shape rows and negative-case non-mutation with independent SQL.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=ProjectServiceIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] cannot find symbol: class CreateProjectCommand
|
||||||
|
[ERROR] cannot find symbol: class ProjectService
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=ProjectServiceIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw test
|
||||||
|
|
||||||
|
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, real concurrent transaction races, Iteration 2 invitations/removals/completion, or Task-module business rules beyond preserving stored assignment IDs. `I1-PRJ-04` remains `IN_PROGRESS`: the activation Task-assignee guard will be implemented only after the Task feature exposes its concrete query service.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Test Evidence: Locked Project context for Task mutations
|
||||||
|
|
||||||
|
- **Test type:** Unit
|
||||||
|
- **Requirement IDs:** `AUTH-001`, `AUTH-011`, `PRJ-012`
|
||||||
|
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-010`, `AC-PRJ-006`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest#loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext`
|
||||||
|
- **Implementation commit:** `19a3518`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Task mutations obtain their Project authorization and current lifecycle, Leader, owning-Mentor, and active-member facts from a DTO-only Project service boundary after the Project row has been locked for update. Missing and unauthorized Projects retain the same non-disclosing denial behavior.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The isolated service test invokes `ProjectService.taskMutationContext(actorUserId, projectId)`, verifies that `ProjectRepository.findLockedById` is used and the ordinary `findById` path is not used, and verifies that only the locked entity is passed to the existing Project-owned authorization and DTO mapper. The PostgreSQL integration test additionally exercises the public API with authorized, unauthorized, current-member, and former-member data.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
Exactly one pessimistic Project lookup occurs before context evaluation. The returned `ProjectTaskContext` exposes scalar/DTO facts only; no Project repository or entity crosses the feature boundary. When called from Task's active transaction, Spring's default `REQUIRED` propagation keeps the row lock in that transaction through its commit or rollback.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectTaskMutationContextTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] constructor ProjectService ... cannot be applied to given types
|
||||||
|
[ERROR] incompatible types: ProjectEntity cannot be converted to long
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
The test failed to compile because Project had no mutation-context API and its context mapper accepted only an unlocked Project ID lookup.
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectTaskMutationContextTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest
|
||||||
|
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest='Project*Test' test
|
||||||
|
|
||||||
|
[INFO] Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
The unit test proves the locked repository path and DTO-only handoff, while the integration coverage proves current Project authorization/member mapping against PostgreSQL 18.4. It does not orchestrate two concurrent database transactions; the lock-retention guarantee relies on the public method's `@Transactional` default `REQUIRED` propagation and the Task caller retaining its outer transaction.
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
- **Test type:** Unit
|
- **Test type:** Unit
|
||||||
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`
|
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`
|
||||||
- **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
|
- **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest`
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest`
|
||||||
- **Implementation commit:** `3483347`
|
- **Implementation commit:** `25a855e`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
|||||||
```text
|
```text
|
||||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
export PATH="$JAVA_HOME/bin:$PATH"
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
./mvnw -Dtest=ProjectTest test
|
./mvnw -Dtest=ProjectEntityTest test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Observed result**
|
**Observed result**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest
|
[INFO] Running com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest
|
||||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
@@ -61,7 +61,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
|||||||
```text
|
```text
|
||||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
export PATH="$JAVA_HOME/bin:$PATH"
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
./mvnw -Dtest=ProjectTest test
|
./mvnw -Dtest=ProjectEntityTest test
|
||||||
|
|
||||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Test Evidence: Project layer and JPA structure
|
||||||
|
|
||||||
|
- **Test type:** Unit
|
||||||
|
- **Requirement IDs:** `ARC-002`, `ARC-005`–`ARC-007`, `OPS-018`–`OPS-020`, `TST-001`–`TST-010`
|
||||||
|
- **Scenario IDs:** `I1-PRJ-01`–`I1-PRJ-05`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa`
|
||||||
|
- **Implementation commit:** `25a855e`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Project-owned production code follows the authoritative feature-first package layout, persists aggregate entities through Spring Data JPA, keeps JDBC operations out of Project business services, and does not shadow Account or Task persistence.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
Plain JUnit inspects the public Project entity, repository, and service types. It verifies their exact feature/layer packages, the entity's JPA mapping, the repository's `JpaRepository` contract, the absence of JDBC service dependencies, and the absence of foreign-table Account/Task shadow entities.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
The Project aggregate is under `feature.project.model.entity`, persistence under `feature.project.repository`, business logic under `feature.project.service`, the service has zero JDBC collaborators, and Account/Task persistence remains owned by those features.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectPersistenceStructureTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] cannot find symbol: class ProjectUserRepository
|
||||||
|
[ERROR] cannot find symbol: class ProjectInternProfileRepository
|
||||||
|
[ERROR] cannot find symbol: class ProjectTaskRepository
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
The RED was observed after removing Project-owned shadow mappings of Account and Task tables. It proves the service still required cross-feature dependencies and could not be made green by retaining forbidden repositories.
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectPersistenceStructureTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest
|
||||||
|
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=LayerStructureTest,ProjectPersistenceStructureTest,ProjectEntityTest test
|
||||||
|
|
||||||
|
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Test Evidence: Authorized Project pages
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001`
|
||||||
|
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
|
||||||
|
- **Implementation commits:** `25a855e`, `a9ee99a`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Authenticated users receive only authorized Project routes; guessed IDs return a non-disclosing not-found response; valid Mentor create requests use the authenticated identity; invalid forms do not mutate; state changes require CSRF.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
MockMvc exercises the real controller, binding, Bean Validation, exception mapping, view selection, redirect, Spring Security authentication, and CSRF filter. Only application/query services are mocked.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
An authorized list request renders `projects/list`. An unauthorized direct ID returns 404. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; a blank name and zero Leader ID render field errors and make no service call. POST without CSRF returns 403.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectControllerTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] cannot find symbol: class ProjectController
|
||||||
|
[ERROR] cannot find symbol: class ProjectPageService
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -Dtest=ProjectControllerTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest
|
||||||
|
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw test
|
||||||
|
|
||||||
|
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mentor-only control regression
|
||||||
|
|
||||||
|
**RED:** the focused MockMvc run reported two expected failures: `GET /projects/new` returned `200` for an Intern instead of non-disclosing `404`, and the member page rendered the `Add member` form for a non-owner.
|
||||||
|
|
||||||
|
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after the controller/DTO/template correction passed 7 tests with zero failures, errors, or skips.
|
||||||
|
|
||||||
|
## Role-aware Project-list action regression
|
||||||
|
|
||||||
|
**RED:** the focused MockMvc run reported two expected failures after adding the list-action regression: the controller still resolved only a user ID, so the Mentor fixture was queried as user `0`, and an Intern-facing Project list rendered the `Create Project` link.
|
||||||
|
|
||||||
|
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips.
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. The activation route remains deferred with `I1-PRJ-04` until the Task feature query dependency is available.
|
||||||
+8
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.project.controller;
|
package com.lab.labtimesheet.feature.project.controller;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
||||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||||
@@ -29,12 +30,17 @@ public class ProjectController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String list(Principal principal, Model model) {
|
public String list(Principal principal, Model model) {
|
||||||
model.addAttribute("projects", pages.listVisible(actorId(principal)));
|
var actor = pages.authenticatedActor(principal.getName());
|
||||||
|
model.addAttribute("projects", pages.listVisible(actor.userId()));
|
||||||
|
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
|
||||||
return "projects/list";
|
return "projects/list";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/new")
|
@GetMapping("/new")
|
||||||
public String createForm(Model model) {
|
public String createForm(Principal principal, Model model) {
|
||||||
|
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
|
||||||
|
throw new ProjectAccessDeniedException();
|
||||||
|
}
|
||||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||||
return "projects/form";
|
return "projects/form";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public record ProjectDetail(
|
|||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate,
|
LocalDate endDate,
|
||||||
String mentorName,
|
String mentorName,
|
||||||
String leaderName) {
|
String leaderName,
|
||||||
|
boolean canManage) {
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -64,7 +64,8 @@ public class ProjectQueryService {
|
|||||||
project.startDate(),
|
project.startDate(),
|
||||||
project.endDate(),
|
project.endDate(),
|
||||||
displayName(project.mentorUserId()),
|
displayName(project.mentorUserId()),
|
||||||
displayName(project.currentLeader().internUserId()));
|
displayName(project.currentLeader().internUserId()),
|
||||||
|
project.mentorUserId() == actorUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -96,7 +97,12 @@ public class ProjectQueryService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
|
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
|
||||||
var project = visibleProject(actorUserId, projectId);
|
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||||
|
return taskContext(actorUserId, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
|
||||||
|
requireVisibleProject(actorUserId, project);
|
||||||
var activeMembers = project.memberships().stream()
|
var activeMembers = project.memberships().stream()
|
||||||
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
|
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
|
||||||
.map(membership -> new ProjectTaskMemberView(
|
.map(membership -> new ProjectTaskMemberView(
|
||||||
@@ -141,15 +147,19 @@ public class ProjectQueryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProjectEntity visibleProject(long actorUserId, long projectId) {
|
private ProjectEntity visibleProject(long actorUserId, long projectId) {
|
||||||
var actor = activeActor(actorUserId);
|
|
||||||
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||||
|
requireVisibleProject(actorUserId, project);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireVisibleProject(long actorUserId, ProjectEntity project) {
|
||||||
|
var actor = activeActor(actorUserId);
|
||||||
var visible = "ADMIN".equals(actor.role().name())
|
var visible = "ADMIN".equals(actor.role().name())
|
||||||
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|
|| ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId)
|
||||||
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
|
|| ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId));
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
throw new ProjectAccessDeniedException();
|
throw new ProjectAccessDeniedException();
|
||||||
}
|
}
|
||||||
return project;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ProjectEntity> visibleProjects(AccountIdentity actor, long actorUserId) {
|
private List<ProjectEntity> visibleProjects(AccountIdentity actor, long actorUserId) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedExcepti
|
|||||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||||
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
||||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||||
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -15,14 +16,17 @@ public class ProjectService {
|
|||||||
|
|
||||||
private final ProjectRepository projects;
|
private final ProjectRepository projects;
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
|
private final ProjectQueryService queries;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public ProjectService(
|
public ProjectService(
|
||||||
ProjectRepository projects,
|
ProjectRepository projects,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
|
ProjectQueryService queries,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
|
this.queries = queries;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +65,11 @@ public class ProjectService {
|
|||||||
projects.flush();
|
projects.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) {
|
||||||
|
return queries.taskContext(actorUserId, lockedProject(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
private ProjectEntity lockedProject(long projectId) {
|
private ProjectEntity lockedProject(long projectId) {
|
||||||
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
|
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
|
||||||
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
|
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
<form method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Projects</h1>
|
<h1>Projects</h1>
|
||||||
<a href="/projects/new">Create Project</a>
|
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
|
||||||
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
||||||
<table th:unless="${#lists.isEmpty(projects)}">
|
<table th:unless="${#lists.isEmpty(projects)}">
|
||||||
<caption>Authorized Projects</caption>
|
<caption>Authorized Projects</caption>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
|
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
|
||||||
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
|
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
<form method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+39
-5
@@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.project.controller;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
@@ -13,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||||
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||||
@@ -41,7 +44,8 @@ class ProjectControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
when(pages.authenticatedActor("mentor@example.test"))
|
||||||
|
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||||
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
|
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
|
||||||
30L,
|
30L,
|
||||||
"Intern Portal Refresh",
|
"Intern Portal Refresh",
|
||||||
@@ -52,11 +56,26 @@ class ProjectControllerTest {
|
|||||||
mvc.perform(get("/projects"))
|
mvc.perform(get("/projects"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("projects/list"))
|
.andExpect(view().name("projects/list"))
|
||||||
.andExpect(model().attributeExists("projects"));
|
.andExpect(model().attributeExists("projects"))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(containsString("Create Project")));
|
||||||
|
|
||||||
verify(pages).listVisible(10L);
|
verify(pages).listVisible(10L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "member@example.test")
|
||||||
|
void nonMentorProjectListOmitsTheCreateLink() throws Exception {
|
||||||
|
when(pages.authenticatedActor("member@example.test"))
|
||||||
|
.thenReturn(new ProjectActorView(20L, "INTERN"));
|
||||||
|
when(pages.listVisible(20L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
mvc.perform(get("/projects"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(not(containsString("Create Project"))));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "member@example.test")
|
@WithMockUser(username = "member@example.test")
|
||||||
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
|
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
|
||||||
@@ -79,16 +98,31 @@ class ProjectControllerTest {
|
|||||||
LocalDate.of(2026, 8, 15),
|
LocalDate.of(2026, 8, 15),
|
||||||
LocalDate.of(2026, 9, 30),
|
LocalDate.of(2026, 9, 30),
|
||||||
"Mentor",
|
"Mentor",
|
||||||
"Leader"));
|
"Leader",
|
||||||
|
false));
|
||||||
when(pages.members(20L, 30L)).thenReturn(List.of());
|
when(pages.members(20L, 30L)).thenReturn(List.of());
|
||||||
when(pages.leadership(20L, 30L)).thenReturn(List.of());
|
when(pages.leadership(20L, 30L)).thenReturn(List.of());
|
||||||
|
|
||||||
mvc.perform(get("/projects/30/members"))
|
mvc.perform(get("/projects/30/members"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("projects/members"));
|
.andExpect(view().name("projects/members"))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(not(containsString("Add member"))));
|
||||||
mvc.perform(get("/projects/30/leadership"))
|
mvc.perform(get("/projects/30/leadership"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("projects/leadership"));
|
.andExpect(view().name("projects/leadership"))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(not(containsString("Change Leader"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "member@example.test")
|
||||||
|
void nonMentorCannotOpenProjectCreationForm() throws Exception {
|
||||||
|
when(pages.authenticatedActor("member@example.test"))
|
||||||
|
.thenReturn(new ProjectActorView(20L, "INTERN"));
|
||||||
|
|
||||||
|
mvc.perform(get("/projects/new"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+5
-2
@@ -144,11 +144,14 @@ class ProjectServiceIntegrationTest {
|
|||||||
assertEquals(List.of(), projectPages.listVisible(unrelatedId));
|
assertEquals(List.of(), projectPages.listVisible(unrelatedId));
|
||||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||||
assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role());
|
assertEquals("INTERN", projectPages.authenticatedActor("member-view@example.test").role());
|
||||||
var taskContext = projectPages.taskContext(memberId, projectId);
|
var taskContext = projectService.taskMutationContext(memberId, projectId);
|
||||||
assertEquals(mentorId, taskContext.mentorUserId());
|
assertEquals(mentorId, taskContext.mentorUserId());
|
||||||
assertEquals("PLANNED", taskContext.status());
|
assertEquals("PLANNED", taskContext.status());
|
||||||
assertEquals(2, taskContext.activeMembers().size());
|
assertEquals(2, taskContext.activeMembers().size());
|
||||||
assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId());
|
assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId());
|
||||||
|
assertEquals(taskContext, projectPages.taskContext(memberId, projectId));
|
||||||
|
assertThrows(ProjectAccessDeniedException.class,
|
||||||
|
() -> projectService.taskMutationContext(otherMentorId, projectId));
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ?
|
update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ?
|
||||||
""", dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId);
|
""", dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId);
|
||||||
@@ -169,7 +172,7 @@ class ProjectServiceIntegrationTest {
|
|||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||||
assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream()
|
assertTrue(projectService.taskMutationContext(memberId, projectId).activeMembers().stream()
|
||||||
.noneMatch(member -> member.userId() == memberId));
|
.noneMatch(member -> member.userId() == memberId));
|
||||||
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
|
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||||
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.lab.labtimesheet.feature.project.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
||||||
|
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||||
|
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ProjectTaskMutationContextTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProjectRepository projects;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProjectQueryService queries;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProjectEntity project;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext() {
|
||||||
|
long actorUserId = 20L;
|
||||||
|
long projectId = 30L;
|
||||||
|
var expected = new ProjectTaskContext(
|
||||||
|
projectId,
|
||||||
|
10L,
|
||||||
|
"ACTIVE",
|
||||||
|
LocalDate.of(2026, 8, 15),
|
||||||
|
LocalDate.of(2026, 9, 30),
|
||||||
|
40L,
|
||||||
|
List.of());
|
||||||
|
var service = new ProjectService(projects, accounts, queries, Clock.systemUTC());
|
||||||
|
when(projects.findLockedById(projectId)).thenReturn(Optional.of(project));
|
||||||
|
when(queries.taskContext(actorUserId, project)).thenReturn(expected);
|
||||||
|
|
||||||
|
var actual = service.taskMutationContext(actorUserId, projectId);
|
||||||
|
|
||||||
|
assertSame(expected, actual);
|
||||||
|
verify(projects).findLockedById(projectId);
|
||||||
|
verify(projects, never()).findById(projectId);
|
||||||
|
verify(queries).taskContext(actorUserId, project);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user